openapi: 3.1.0
info:
  title: Zernio API
  version: "1.0.1"
  description: |
    API reference for Zernio. Authenticate with a Bearer API key.
    Base URL: https://zernio.com/api
  termsOfService: https://zernio.com/tos
  contact:
    name: Zernio Support
    url: https://zernio.com
    email: support@zernio.com
  # RapidAPI extensions for Hub listing
  x-logo:
    url: https://zernio.com/icon.png?v=3
  x-long-description: |
    Zernio is the social media API that replaces 15 integrations. Schedule posts, retrieve analytics,
    manage DMs, comments, and reviews across Twitter/X, Instagram, TikTok, LinkedIn, Facebook,
    YouTube, Threads, Reddit, Pinterest, Bluesky, Telegram, Google Business, Snapchat, and Discord,
    all from a single REST API. Run paid ads on Meta (Facebook + Instagram), Google, TikTok,
    LinkedIn, Pinterest, and X from the same account.

    Key features: Unified posting to 15 platforms, ads management on 6 ad networks (via /v1/ads), aggregated analytics, unified inbox (DMs, comments, reviews), webhooks, OAuth connect, queue scheduling, and white-label support for agencies managing unlimited accounts.

    Supported posting platforms: Twitter/X, Instagram, WhatsApp, Facebook, LinkedIn, TikTok, YouTube, Pinterest, Reddit, Bluesky, Threads, Google Business, Telegram, Snapchat, Discord. Supported ad platforms: Meta Ads, Google Ads, TikTok Ads, LinkedIn Ads, Pinterest Ads, X Ads.
  x-category: Social
  x-website: https://zernio.com
  x-thumbnail: https://rapidapi-prod-apis.s3.amazonaws.com/b24d3df5-563c-4a50-9e1e-1ad3eb1fce69.png
  x-version-lifecycle: ACTIVE
  x-badges:
    - name: "social media"
      value: "social media"
    - name: "scheduling"
      value: "scheduling"
    - name: "instagram"
      value: "instagram"
    - name: "tiktok"
      value: "tiktok"
    - name: "twitter"
      value: "twitter"
    - name: "linkedin"
      value: "linkedin"
    - name: "facebook"
      value: "facebook"
    - name: "youtube"
      value: "youtube"
    - name: "social media api"
      value: "social media api"
    - name: "posting"
      value: "posting"

# RapidAPI Hub documentation tab (README)
x-documentation:
  readme: |
    # Zernio API

    The social media API that replaces 14 integrations. Build social media features into your app in minutes, not months.

    ## Quick Start

    **Base URL:** `https://zernio.com/api/v1`

    **Authentication:** All requests require a Bearer API key in the `Authorization` header.

    ```bash
    curl https://zernio.com/api/v1/user \
      -H "Authorization: Bearer YOUR_API_KEY"
    ```

    Get your API key at [zernio.com/dashboard/api-keys](https://zernio.com/dashboard/api-keys).

    ## Core Concepts

    | Concept | Description |
    |---------|-------------|
    | **Profiles** | Containers that organize social accounts into brands or projects |
    | **Accounts** | Connected social media accounts belonging to a profile |
    | **Posts** | Content scheduled or published to one or more accounts |
    | **Queue** | Recurring time slots for automatic post scheduling |

    ## Create a Post

    ```bash
    curl -X POST https://zernio.com/api/v1/post \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{
        "profileId": "your-profile-id",
        "text": "Hello from Zernio API!",
        "socialAccountIds": ["account-1", "account-2"],
        "scheduledAt": "2025-01-15T10:00:00Z"
      }'
    ```

    This single call publishes or schedules the post to all selected accounts across any platform.

    ## Supported Platforms

    | Platform | Post | Stories/Reels | Analytics | Inbox |
    |----------|------|---------------|-----------|-------|
    | Twitter/X | Yes | - | Yes | Yes |
    | Instagram | Yes | Yes | Yes | Yes |
    | Facebook | Yes | Stories | Yes | Yes |
    | LinkedIn | Yes | - | Partial | - |
    | TikTok | Yes | - | Yes | - |
    | YouTube | Yes | Shorts | Yes | Yes |
    | Pinterest | Yes | - | Yes | - |
    | Reddit | Yes | - | - | Yes |
    | Bluesky | Yes | - | - | Yes |
    | Threads | Yes | - | Yes | Yes |
    | Google Business | Yes | - | - | Yes |
    | Telegram | Yes | - | - | - |
    | Snapchat | Yes | - | - | - |

    > **LinkedIn Analytics Note:** For personal LinkedIn accounts, analytics are only available for posts published through Zernio. This is a LinkedIn API limitation: the `memberCreatorPostAnalytics` endpoint only returns metrics for posts authored by the authenticated user. Company/organization page analytics are not affected and work for all posts.

    > **Google Business Analytics Note:** Per-post analytics for Google Business Profile are deprecated by Google with no replacement. Location-level metrics (impressions, clicks, calls, directions, bookings) are available via the dedicated `/v1/analytics/googlebusiness/performance` endpoint.

    ## Rate Limits

    | Plan | Requests/min | Posts/month |
    |------|-------------|-------------|
    | Free | 60 | 20 |
    | Build | 120 | 120 |
    | Accelerate | 600 | Unlimited |
    | Unlimited | 1,200 | Unlimited |

    All responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers.

    ## Webhooks

    Receive real-time notifications for post status changes, account events, and incoming messages:

    - `post.scheduled` - Post created and scheduled for publishing
    - `post.published` - Post successfully published
    - `post.failed` - Post failed on all platforms
    - `post.partial` - Post published to some platforms, failed on others
    - `post.cancelled` - Post publishing was cancelled
    - `post.recycled` - Post recycled (cloned and re-scheduled)
    - `account.connected` - Social account connected
    - `account.disconnected` - Social account disconnected (token expired)
    - `account.ads.initial_sync_completed` - Initial ads sync (discovery + 90-day backfill) completed for an ads-enabled account
    - `message.received` - New DM received
    - `message.sent` - DM sent via the API
    - `message.edited` - A sender edited a message (Instagram, Messenger, Telegram)
    - `message.deleted` - A sender deleted ("unsent") a message (Instagram; WhatsApp when the business deletes a sent message)
    - `message.delivered` - An outgoing message was delivered (WhatsApp, Messenger)
    - `message.read` - An outgoing message was read by the recipient (WhatsApp, Messenger, Instagram)
    - `message.failed` - An outgoing message failed delivery (WhatsApp)
    - `comment.received` - New comment received on a post
    - `review.new` - New review posted on a connected account (Google Business Profile)
    - `review.updated` - Review updated or reply added (Google Business Profile, or via reply API)
    - `webhook.test` - Test event sent when verifying a webhook endpoint

    Webhook payloads are signed with HMAC-SHA256 via the `X-Zernio-Signature` header.

    ## Full Documentation

    For complete guides, platform-specific details, and SDK references, visit [docs.zernio.com](https://docs.zernio.com).

    ## SDKs

    Official SDKs available for: [Node.js](https://www.npmjs.com/package/@zernio/node), [Python](https://pypi.org/project/zernio-sdk), Go, Ruby, Java, PHP, .NET, and Rust.

servers:
  - url: https://zernio.com/api
    description: Production
  - url: http://localhost:3000/api
    description: Local
tags:
  - name: Posts
  - name: Users
  - name: Usage
  - name: Profiles
  - name: Accounts
  - name: Account Groups
  - name: API Keys
  - name: Invites
  - name: Connect
  - name: Media
  - name: Reddit Search
  - name: Facebook
  - name: GMB Reviews
  - name: GMB Food Menus
  - name: GMB Location Details
  - name: GMB Media
  - name: GMB Attributes
  - name: GMB Place Actions
  - name: Discord
    description: |
      Discord-specific endpoints for managing webhook identity (display name and avatar), switching channels, and listing guild channels.
  - name: LinkedIn Mentions
  - name: Pinterest
  - name: TikTok
  - name: Queue
  - name: Analytics
    description: |
      Cross-platform analytics for connected social accounts: per-post timelines, account-level insights,
      follower history, demographics, posting frequency, and best-time-to-post recommendations.

      **Requires the Analytics add-on.** Endpoints in this group return `402` with code
      `analytics_addon_required` when the calling user (or root user, for invited team members) does not
      have the addon active. Subscribe via the Zernio dashboard or via Stripe.
  - name: Inbox Access
    description: |
      Check and manage inbox feature access.
  - name: Messages
    description: |
      Unified inbox API for managing conversations and direct messages across all connected accounts.
      All endpoints aggregate data from multiple social accounts in a single API call.
      Requires Inbox addon.
  - name: Comments
    description: |
      Unified inbox API for managing comments on posts across all connected accounts.
      Supports commenting on third-party posts for platforms that allow it (YouTube, Twitter, Reddit, Bluesky, Threads).
      All endpoints aggregate data from multiple social accounts in a single API call.
      Requires Inbox addon.
  - name: Reviews
    description: |
      Unified inbox API for managing reviews on Facebook Pages and Google Business accounts.
      All endpoints aggregate data from multiple social accounts in a single API call.
      Requires Inbox addon.
  - name: Twitter Engagement
    description: |
      X/Twitter-specific engagement endpoints for retweeting, bookmarking, and following.
      Rate limits: 50 requests per 15-min window per user. Retweets share the 300/3hr creation limit with tweet creation.
  - name: Validate
    description: |
      Pre-flight validation endpoints. Check post content, character limits, media URLs, and subreddit existence before publishing.
  - name: Account Settings
    description: |
      Platform-specific account settings: Facebook persistent menu, Instagram ice breakers, and Telegram bot commands.
  - name: Contacts
    description: |
      Cross-platform contact management (CRM). Contacts are unified identities linked to platform-specific
      channels (phone, IGSID, etc.). Created automatically when messages arrive, or manually via API.
  - name: Custom Fields
    description: |
      Custom field definitions for contacts. Define fields (text, number, date, boolean, select) that can be
      set on any contact for segmentation and personalization.
  - name: Broadcasts
    description: |
      Platform-agnostic broadcast campaigns. Send bulk messages to contacts via any inbox platform.
      WhatsApp broadcasts use templates; other platforms use generic messages.
  - name: Sequences
    description: |
      Drip campaign sequences. Send a series of messages to enrolled contacts with configurable delays
      between steps. Supports auto-exit on reply or unsubscribe.
  - name: Comment Automations
    description: |
      Comment-to-DM growth automations. Set up keyword triggers on Instagram/Facebook posts so
      commenters automatically receive a DM. Supports dedup, optional public comment reply, and
      auto-creates contacts.
  - name: Ads
    description: |
      Paid advertising management across Meta (Facebook/Instagram), Google, TikTok, LinkedIn, Pinterest, and X/Twitter.
      Create, boost, pause/resume ads and campaigns, view metrics, and manage audiences.
      Requires the Ads add-on.
  - name: Ad Campaigns
    description: |
      Campaign-level operations. Campaigns are virtual aggregations of ads grouped by their platform campaign ID.
      List campaigns with aggregate metrics, or pause/resume all ads in a campaign at once.
      Requires the Ads add-on.
  - name: Ad Audiences
    description: |
      Custom audience management for ad targeting. Create customer lists, website retargeting audiences,
      and lookalike audiences. Upload user data (hashed server-side). Currently Meta-only for creation,
      read-only for other platforms.
      Requires the Ads add-on.
  - name: Webhooks
    description: |
      Configure webhooks for real-time notifications. Events: post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled, account.connected, account.disconnected, account.ads.initial_sync_completed, message.received, message.sent, comment.received, review.new, review.updated, webhook.test.
      Security: optional HMAC-SHA256 signature in X-Zernio-Signature header. Configure a secret key to enable verification. Custom headers supported.
  - name: Webhook Events
    description: |
      Incoming webhook deliveries sent by Zernio to your configured endpoint URL.
  - name: Logs
    description: |
      Publishing logs for transparency and debugging. Each log includes the platform API endpoint, HTTP status code, request/response bodies, duration, and retry attempts. Logs are automatically deleted after 7 days.
  - name: WhatsApp
    description: |
      WhatsApp Business API. Template, business profile, and phone number endpoints.
      All endpoints require an accountId parameter identifying the WhatsApp-connected social account.
  - name: WhatsApp Flows
    description: |
      WhatsApp Flows let you build native interactive forms, surveys, and booking experiences inside WhatsApp.
      Flows are created in DRAFT status, populated with a Flow JSON definition, then published for sending.
      Published flows are immutable; to update, create a new flow (optionally cloning the old one).
      All endpoints require an accountId parameter identifying the WhatsApp-connected social account.
  - name: WhatsApp Phone Numbers
    description: |
      Manage WhatsApp phone numbers: purchase, verify, and release numbers for your WhatsApp Business account.
      Requires a paid plan.
components:
  securitySchemes:
    bearerAuth:
      type: http
      scheme: bearer
      bearerFormat: JWT
      description: API key authentication - use your Zernio API key as a Bearer token
    connectToken:
      type: apiKey
      in: header
      name: X-Connect-Token
      description: |
        Short-lived connect token for API users during OAuth flows. 
        Automatically generated when initiating OAuth without a browser session.
        Valid for 15 minutes. Used to authenticate Facebook page selection API calls.
  parameters:
    PageParam:
      name: page
      in: query
      description: Page number (1-based)
      schema: { type: integer, minimum: 1, default: 1 }
    LimitParam:
      name: limit
      in: query
      description: Page size
      schema: { type: integer, minimum: 1, maximum: 100, default: 10 }
  responses:
    Unauthorized:
      description: Unauthorized
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                example: Unauthorized
    NotFound:
      description: Resource not found
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                example: Not found
  schemas:
    RedditPost:
      type: object
      description: A normalized Reddit post returned by the feed and search endpoints
      properties:
        id: { type: string, description: Reddit post ID (without type prefix) }
        fullname: { type: string, description: "Reddit fullname (e.g. t3_abc123)" }
        title: { type: string }
        author: { type: string }
        subreddit: { type: string }
        url: { type: string, description: Post URL (may be a gallery URL, external link, or self-post URL) }
        permalink: { type: string, description: Full permalink to the Reddit post }
        selftext: { type: string, description: Self-post body text (empty string for link posts) }
        createdUtc: { type: number, description: Unix timestamp of post creation }
        score: { type: integer }
        numComments: { type: integer }
        over18: { type: boolean, description: Whether the post is marked NSFW }
        stickied: { type: boolean }
        flairText: { type: string, nullable: true, description: Link flair text if set }
        isGallery: { type: boolean, description: Whether the post is a gallery with multiple images }
        galleryImages:
          type: array
          description: Individual image URLs for gallery posts (only present when isGallery is true)
          items: { type: string, format: uri }
    ErrorResponse:
      type: object
      properties:
        error:
          type: string
        details:
          type: object
          additionalProperties: true
    WhatsAppTemplateButton:
      type: object
      required: [type, text]
      properties:
        type:
          type: string
          enum: [quick_reply, url, phone_number, otp, flow, mpm, catalog]
        text:
          type: string
        url:
          type: string
          format: uri
          description: Required when type is URL
        example:
          type: array
          items: { type: string }
          description: Example values for URL suffix variables
        phone_number:
          type: string
          description: Required when type is phone_number
        otp_type:
          type: string
          enum: [copy_code, one_tap, zero_tap]
          description: Required when type is otp
        autofill_text:
          type: string
        package_name:
          type: string
        signature_hash:
          type: string
        flow_id:
          type: string
        flow_name:
          type: string
        flow_json:
          type: string
        flow_action:
          type: string
        navigate_screen:
          type: string
    WhatsAppTemplateComponent:
      oneOf:
        - $ref: '#/components/schemas/WhatsAppHeaderComponent'
        - $ref: '#/components/schemas/WhatsAppBodyComponent'
        - $ref: '#/components/schemas/WhatsAppFooterComponent'
        - $ref: '#/components/schemas/WhatsAppButtonsComponent'
      discriminator:
        propertyName: type
        mapping:
          header: '#/components/schemas/WhatsAppHeaderComponent'
          body: '#/components/schemas/WhatsAppBodyComponent'
          footer: '#/components/schemas/WhatsAppFooterComponent'
          buttons: '#/components/schemas/WhatsAppButtonsComponent'
    WhatsAppHeaderComponent:
      type: object
      required: [type, format]
      properties:
        type:
          type: string
          enum: [header]
        format:
          type: string
          enum: [text, image, video, gif, document, location]
        text:
          type: string
          description: Header text (may include {{1}} variable). Used when format is TEXT.
        example:
          type: object
          properties:
            header_text:
              type: array
              items: { type: string }
              description: Sample values for header text variables
            header_handle:
              type: array
              minItems: 1
              maxItems: 1
              items:
                type: string
                format: uri
              description: When the header format is a media type (image, video, gif, document), provide a public URL here. Zernio will download and upload it to WhatsApp on your behalf, replacing it with the internal file handle before creating the template.
    WhatsAppBodyComponent:
      type: object
      required: [type, text]
      properties:
        type:
          type: string
          enum: [body]
        text:
          type: string
          description: Body text with optional {{n}} variables
        add_security_recommendation:
          type: boolean
          description: Add security recommendation text (authentication templates only)
        example:
          type: object
          properties:
            body_text:
              type: array
              items:
                type: array
                items: { type: string }
              description: Sample values for body variables (array of arrays)
    WhatsAppFooterComponent:
      type: object
      required: [type]
      properties:
        type:
          type: string
          enum: [footer]
        text:
          type: string
          description: Static footer text
        code_expiration_minutes:
          type: integer
          minimum: 1
          description: OTP code expiry in minutes (authentication templates only)
    WhatsAppButtonsComponent:
      type: object
      required: [type, buttons]
      properties:
        type:
          type: string
          enum: [buttons]
        buttons:
          type: array
          minItems: 1
          items:
            $ref: '#/components/schemas/WhatsAppTemplateButton'
    FoodMenuLabel:
      type: object
      required: [displayName]
      properties:
        displayName: { type: string, description: Display name of the item/section/menu }
        description: { type: string, description: Optional description }
        languageCode: { type: string, description: "BCP-47 language code (e.g. en, es)" }
    Money:
      type: object
      required: [currencyCode, units]
      properties:
        currencyCode: { type: string, description: "ISO 4217 currency code (e.g. USD, EUR)" }
        units: { type: string, description: Whole units of the amount }
        nanos: { type: integer, description: Nano units (10^-9) of the amount }
    FoodMenuItemAttributes:
      type: object
      properties:
        price: { $ref: '#/components/schemas/Money' }
        spiciness: { type: string, description: "Spiciness level (e.g. MILD, MEDIUM, HOT)" }
        allergen:
          type: array
          items: { type: string }
          description: "Allergens (e.g. DAIRY, GLUTEN, SHELLFISH)"
        dietaryRestriction:
          type: array
          items: { type: string }
          description: "Dietary labels (e.g. VEGETARIAN, VEGAN, GLUTEN_FREE)"
        servesNumPeople: { type: integer, description: Number of people the item serves }
        preparationMethods:
          type: array
          items: { type: string }
          description: "Preparation methods (e.g. GRILLED, FRIED)"
        mediaKeys:
          type: array
          items: { type: string }
          description: Media references for item photos
    FoodMenuItem:
      type: object
      required: [labels]
      properties:
        labels:
          type: array
          items: { $ref: '#/components/schemas/FoodMenuLabel' }
        attributes: { $ref: '#/components/schemas/FoodMenuItemAttributes' }
        options:
          type: array
          items:
            type: object
            properties:
              labels:
                type: array
                items: { $ref: '#/components/schemas/FoodMenuLabel' }
              attributes: { $ref: '#/components/schemas/FoodMenuItemAttributes' }
          description: Item variants/options (e.g. sizes, preparations)
    FoodMenuSection:
      type: object
      required: [labels]
      properties:
        labels:
          type: array
          items: { $ref: '#/components/schemas/FoodMenuLabel' }
        items:
          type: array
          items: { $ref: '#/components/schemas/FoodMenuItem' }
    FoodMenu:
      type: object
      required: [labels]
      properties:
        labels:
          type: array
          items: { $ref: '#/components/schemas/FoodMenuLabel' }
        sections:
          type: array
          items: { $ref: '#/components/schemas/FoodMenuSection' }
        cuisines:
          type: array
          items: { type: string }
          description: "Cuisine types (e.g. AMERICAN, ITALIAN, JAPANESE)"
        sourceUrl:
          type: string
          description: URL of the original menu source
    YouTubeDailyViewsResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        videoId:
          type: string
          description: The YouTube video ID
        dateRange:
          type: object
          properties:
            startDate:
              type: string
              format: date
            endDate:
              type: string
              format: date
        totalViews:
          type: integer
          description: Sum of views across all days in the range
        dailyViews:
          type: array
          items:
            type: object
            properties:
              date:
                type: string
                format: date
              views:
                type: integer
              estimatedMinutesWatched:
                type: number
              averageViewDuration:
                type: number
                description: Average view duration in seconds
              subscribersGained:
                type: integer
              subscribersLost:
                type: integer
              likes:
                type: integer
              comments:
                type: integer
              shares:
                type: integer
        lastSyncedAt:
          type: string
          format: date-time
          nullable: true
          description: When the data was last synced from YouTube
        scopeStatus:
          type: object
          properties:
            hasAnalyticsScope:
              type: boolean
    YouTubeScopeMissingResponse:
      type: object
      properties:
        success:
          type: boolean
          example: false
        error:
          type: string
          example: "To access daily video analytics, please reconnect your YouTube account to grant the required permissions."
        code:
          type: string
          example: youtube_analytics_scope_missing
        scopeStatus:
          type: object
          properties:
            hasAnalyticsScope:
              type: boolean
              example: false
            requiresReauthorization:
              type: boolean
              example: true
            reauthorizeUrl:
              type: string
              format: uri
              description: URL to redirect user for reauthorization
    InstagramAccountInsightsResponse:
      type: object
      description: |
        Shared account-insights response envelope used by every platform-level
        analytics endpoint (/v1/analytics/{facebook|instagram|youtube|linkedin|tiktok}/*).
        The name is historical - the shape was first shipped for Instagram and every
        new platform endpoint reuses it for response-shape consistency. The platform
        field echoes back which platform served the response.
      properties:
        success:
          type: boolean
          example: true
        accountId:
          type: string
          description: The Zernio SocialAccount ID
        platform:
          type: string
          description: Platform that served this response.
          enum: [facebook, instagram, youtube, linkedin, tiktok]
        dateRange:
          type: object
          properties:
            since:
              type: string
              format: date
            until:
              type: string
              format: date
        metricType:
          type: string
          enum: [time_series, total_value]
        breakdown:
          type: string
          description: Breakdown dimension used (only present when breakdown was requested)
        metrics:
          type: object
          description: |
            Object keyed by metric name. For time_series: each metric has "total" (number) and "values" (array of {date, value}).
            For total_value: each metric has "total" (number) and optionally "breakdowns" (array of {dimension, value}).
          additionalProperties:
            type: object
            properties:
              total:
                type: number
                description: Sum or aggregate value for the metric
              values:
                type: array
                description: Daily values (only for time_series)
                items:
                  type: object
                  properties:
                    date:
                      type: string
                      format: date
                    value:
                      type: number
              breakdowns:
                type: array
                description: Breakdown values (only for total_value with breakdown)
                items:
                  type: object
                  properties:
                    dimension:
                      type: string
                    value:
                      type: number
        dataDelay:
          type: string
          example: "Data may be delayed up to 48 hours"
    InstagramDemographicsResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        accountId:
          type: string
          description: The Zernio SocialAccount ID
        platform:
          type: string
          example: "instagram"
        metric:
          type: string
          enum: [follower_demographics, engaged_audience_demographics]
        timeframe:
          type: string
          enum: [this_week, this_month]
          description: The timeframe used for demographic data
        demographics:
          type: object
          description: Object keyed by breakdown dimension (age, city, country, gender)
          additionalProperties:
            type: array
            items:
              type: object
              properties:
                dimension:
                  type: string
                  description: The dimension value (e.g., "25-34", "US", "M")
                value:
                  type: number
                  description: Count of accounts in this dimension
        note:
          type: string
          example: "Demographics show top 45 entries per dimension. Requires 100+ followers."
    YouTubeDemographicsResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        accountId:
          type: string
          description: The Zernio SocialAccount ID
        platform:
          type: string
          example: "youtube"
        demographics:
          type: object
          description: Object keyed by breakdown dimension (age, gender, country)
          additionalProperties:
            type: array
            items:
              type: object
              properties:
                dimension:
                  type: string
                  description: The dimension value (e.g., "25-34", "US", "male")
                value:
                  type: number
                  description: Viewer percentage (age/gender) or view count (country)
        dateRange:
          type: object
          properties:
            startDate:
              type: string
              example: "2026-01-01"
            endDate:
              type: string
              example: "2026-03-31"
        note:
          type: string
          example: "Age/gender values are viewer percentages (0-100). Country values are view counts. Data based on signed-in viewers only, with 2-3 day delay."
    Webhook:
      type: object
      description: Individual webhook configuration for receiving real-time notifications
      properties:
        _id:
          type: string
          description: Unique webhook identifier
        name:
          type: string
          description: Webhook name (for identification)
          maxLength: 50
        url:
          type: string
          format: uri
          description: Webhook endpoint URL
        secret:
          type: string
          description: Secret key for HMAC-SHA256 signature (not returned in responses for security)
        events:
          type: array
          items:
            type: string
            enum: [post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled, account.connected, account.disconnected, account.ads.initial_sync_completed, message.received, message.sent, message.edited, message.deleted, message.delivered, message.read, message.failed, comment.received, review.new, review.updated]
          description: Events subscribed to
        isActive:
          type: boolean
          description: Whether webhook delivery is enabled
        lastFiredAt:
          type: string
          format: date-time
          description: Timestamp of last successful webhook delivery
        failureCount:
          type: integer
          description: Consecutive delivery failures (resets on success, webhook disabled at 10)
        customHeaders:
          type: object
          additionalProperties:
            type: string
          description: Custom headers included in webhook requests
    WebhookPayloadPost:
      type: object
      description: Webhook payload for post events
      required: [id, event, post, timestamp]
      properties:
        id:
          type: string
          description: Stable webhook event ID
        event:
          type: string
          enum: [post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled]
        post:
          type: object
          required: [id, content, status, scheduledFor, platforms]
          properties:
            id:
              type: string
            content:
              type: string
            status:
              type: string
            scheduledFor:
              type: string
              format: date-time
            publishedAt:
              type: string
              format: date-time
            platforms:
              type: array
              items:
                type: object
                required: [platform, status]
                properties:
                  platform:
                    type: string
                  status:
                    type: string
                  platformPostId:
                    type: string
                  publishedUrl:
                    type: string
                  error:
                    type: string
        timestamp:
          type: string
          format: date-time
    WebhookPayloadAccountConnected:
      type: object
      description: Webhook payload for account connected events
      required: [id, event, account, timestamp]
      properties:
        id:
          type: string
          description: Stable webhook event ID
        event:
          type: string
          enum: [account.connected]
        account:
          type: object
          required: [accountId, profileId, platform, username]
          properties:
            accountId:
              type: string
              description: The account's unique identifier (same as used in /v1/accounts/{accountId})
            profileId:
              type: string
              description: The profile's unique identifier this account belongs to
            platform:
              type: string
            username:
              type: string
            displayName:
              type: string
        timestamp:
          type: string
          format: date-time
    WebhookPayloadAccountDisconnected:
      type: object
      description: Webhook payload for account disconnected events
      required: [id, event, account, timestamp]
      properties:
        id:
          type: string
          description: Stable webhook event ID
        event:
          type: string
          enum: [account.disconnected]
        account:
          type: object
          required: [accountId, profileId, platform, username, disconnectionType, reason]
          properties:
            accountId:
              type: string
              description: The account's unique identifier (same as used in /v1/accounts/{accountId})
            profileId:
              type: string
              description: The profile's unique identifier this account belongs to
            platform:
              type: string
            username:
              type: string
            displayName:
              type: string
            disconnectionType:
              type: string
              enum: [intentional, unintentional]
              description: Whether the disconnection was intentional (user action) or unintentional (token expired/revoked)
            reason:
              type: string
              description: Human-readable reason for the disconnection
        timestamp:
          type: string
          format: date-time
    WebhookPayloadAccountAdsInitialSyncCompleted:
      type: object
      description: |
        Webhook payload for `account.ads.initial_sync_completed` events.
        Fired once per ads-enabled account when the initial discovery + 90-day
        ad backfill finishes (whether it succeeded fully, partially, or failed).
      required: [id, event, account, sync, timestamp]
      properties:
        id:
          type: string
          description: Stable webhook event ID
        event:
          type: string
          enum: [account.ads.initial_sync_completed]
        account:
          type: object
          required: [accountId, profileId, platform, username]
          properties:
            accountId:
              type: string
              description: The account's unique identifier (same as used in /v1/accounts/{accountId})
            profileId:
              type: string
              description: The profile's unique identifier this account belongs to
            platform:
              type: string
            username:
              type: string
            displayName:
              type: string
            platformUserId:
              type: string
              description: The platform-side account/ad-account ID (e.g. Meta ad account ID).
            profilePicture:
              type: string
              format: uri
              description: URL of the account's profile picture, when available.
        sync:
          type: object
          description: Summary of the initial ads sync backfill results.
          required: [status, totalAds, synced, failed]
          properties:
            status:
              type: string
              enum: [success, failure]
              description: Overall outcome of the initial sync.
            totalAds:
              type: integer
              description: Total number of ads discovered for backfill.
            synced:
              type: integer
              description: Number of ads successfully synced.
            failed:
              type: integer
              description: Number of ads that failed to sync.
        timestamp:
          type: string
          format: date-time
    WebhookPayloadComment:
      type: object
      description: Webhook payload for comment received events (Instagram, Facebook, Twitter/X, YouTube, LinkedIn, Bluesky, Reddit)
      required: [id, event, comment, post, account, timestamp]
      properties:
        id:
          type: string
          description: Stable webhook event ID
        event:
          type: string
          enum: [comment.received]
        comment:
          type: object
          required: [id, postId, platformPostId, platform, text, author, createdAt, isReply, parentCommentId]
          properties:
            id:
              type: string
              description: Platform comment ID
            postId:
              type: string
              nullable: true
              description: Internal post ID (null for posts not published through Zernio)
            platformPostId:
              type: string
              description: Platform's post ID
            platform:
              type: string
              enum: [instagram, facebook, twitter, youtube, linkedin, bluesky, reddit]
            text:
              type: string
              description: Comment text content
            author:
              type: object
              required: [id]
              properties:
                id:
                  type: string
                  description: Author's platform ID
                username:
                  type: string
                name:
                  type: string
                picture:
                  type: string
                  nullable: true
            createdAt:
              type: string
              format: date-time
            isReply:
              type: boolean
              description: Whether this is a reply to another comment
            parentCommentId:
              type: string
              nullable: true
              description: Parent comment ID if this is a reply
            ad:
              type: object
              description: |
                Ad context. Present only when the comment was made on paid content.
                Instagram: populated from the webhook payload's value.media.ad_id and value.media.ad_title.
                Facebook: populated via a Graph API lookup of the parent post's promotion_status.
                Absent for comments on organic posts that are not currently promoted.
              properties:
                id:
                  type: string
                  description: Meta ad ID (Instagram only).
                title:
                  type: string
                  description: Ad creative title (Instagram only).
                promotionStatus:
                  type: string
                  description: |
                    Facebook promotion status returned by Graph API. Common values:
                    "active" (organic post currently boosted), "ineligible" (dark
                    post or ad creative, not promotable because it already is an ad).
        post:
          type: object
          required: [id, platformPostId]
          properties:
            id:
              type: string
              nullable: true
              description: Internal post ID (null for posts not published through Zernio)
            platformPostId:
              type: string
              description: Platform's post ID
        account:
          type: object
          required: [id, platform, username]
          properties:
            id:
              type: string
              description: Social account ID
            platform:
              type: string
            username:
              type: string
        timestamp:
          type: string
          format: date-time
    ReviewWebhookReview:
      type: object
      description: Review data shared by review.new and review.updated payloads.
      required: [id, platform, rating, text, reviewer, createdAt, hasReply]
      properties:
        id:
          type: string
          description: Platform review ID (e.g. "accounts/123/locations/456/reviews/789" for Google Business).
        platform:
          type: string
          enum: [googlebusiness]
          description: Platform the review originated on. Currently Google Business Profile only.
        rating:
          type: integer
          minimum: 1
          maximum: 5
          description: Star rating the reviewer gave.
        text:
          type: string
          description: Review text content. May be empty if the reviewer left only a rating.
        reviewer:
          type: object
          required: [id, name, profileImage]
          properties:
            id:
              type: string
              nullable: true
              description: Platform reviewer ID. Null when the platform does not expose it (common on Google Business anonymous reviews).
            name:
              type: string
            profileImage:
              type: string
              nullable: true
        createdAt:
          type: string
          format: date-time
        hasReply:
          type: boolean
          description: Whether the connected account has replied to this review.
        reply:
          type: object
          description: Present when hasReply is true.
          required: [text, createdAt]
          properties:
            text:
              type: string
            createdAt:
              type: string
              format: date-time
    WebhookPayloadReviewNew:
      type: object
      description: Webhook payload for the review.new event (new review posted on a connected account).
      required: [id, event, review, account, timestamp]
      properties:
        id:
          type: string
          description: Stable webhook event ID
        event:
          type: string
          enum: [review.new]
        review:
          $ref: '#/components/schemas/ReviewWebhookReview'
        account:
          type: object
          required: [id, platform, username]
          properties:
            id:
              type: string
            platform:
              type: string
            username:
              type: string
        timestamp:
          type: string
          format: date-time
    WebhookPayloadReviewUpdated:
      type: object
      description: |
        Webhook payload for the review.updated event. Fired when the reviewer edits
        their text or rating, or when a reply is added (via the API or directly on the
        platform). Same shape as review.new. When a reply is present, review.hasReply
        is true and review.reply is populated.
      required: [id, event, review, account, timestamp]
      properties:
        id:
          type: string
          description: Stable webhook event ID
        event:
          type: string
          enum: [review.updated]
        review:
          $ref: '#/components/schemas/ReviewWebhookReview'
        account:
          type: object
          required: [id, platform, username]
          properties:
            id:
              type: string
            platform:
              type: string
            username:
              type: string
        timestamp:
          type: string
          format: date-time
    # ─── Shared sub-schemas for inbox lifecycle webhook payloads ────────────
    # WebhookPayloadMessageEdited / Deleted / DeliveryStatus reference these
    # as $refs. They were added as top-level named schemas (rather than nested
    # refs into WebhookPayloadMessage.properties) because openapi-typescript
    # and datamodel-code-generator can't resolve nested-property refs — the
    # SDK regen fails with "Cannot find name 'WebhookPayloadMessage_properties_message'".
    InboxWebhookMessage:
      type: object
      description: The message object included in inbox webhook payloads.
      required: [id, conversationId, platform, platformMessageId, direction, text, attachments, sender, sentAt, isRead]
      properties:
        id:
          type: string
          description: Internal message ID
        conversationId:
          type: string
          description: Internal conversation ID
        platform:
          type: string
          enum: [instagram, facebook, telegram, whatsapp]
        platformMessageId:
          type: string
          description: Platform's message ID
        direction:
          type: string
          enum: [incoming, outgoing]
        text:
          type: string
          nullable: true
          description: Message text content (retained on deleted messages for API consumers; Zernio dashboard UI hides this)
        attachments:
          type: array
          items:
            type: object
            required: [type, url]
            properties:
              type:
                type: string
                description: Attachment type (image, video, file, sticker, audio)
              url:
                type: string
                description: Attachment URL (may expire for Meta platforms)
              payload:
                type: object
                description: Additional attachment metadata
        sender:
          type: object
          required: [id]
          properties:
            id:
              type: string
              description: |
                Sender's platform identifier. For WhatsApp this is the phone number
                (without leading `+`) when available, otherwise the `businessScopedUserId`.
                For other platforms, the platform's own user ID.
            name:
              type: string
            username:
              type: string
            picture:
              type: string
            phoneNumber:
              type: string
              nullable: true
              description: |
                WhatsApp only. Sender's phone number in E.164 format (with leading `+`).

                **Nullable during the BSUID rollout (April 2026+).** WhatsApp users
                who adopt a username can message businesses without exposing a phone
                number — this field is omitted for them. Match by `businessScopedUserId`
                instead. See `docs/whatsapp-bsuid-migration.md`.
            businessScopedUserId:
              type: string
              description: |
                WhatsApp only. Business-scoped user ID (BSUID) — Meta's canonical
                identifier for a WhatsApp user within your business. Present when
                Meta includes it in the inbound payload (rollout in progress since
                early April 2026). **Recommended primary identity anchor** going
                forward; fall back to `phoneNumber` only when this field is absent.
            parentBusinessScopedUserId:
              type: string
              description: |
                WhatsApp only. Parent BSUID for businesses with linked business
                portfolios. Omitted for standalone portfolios.
            whatsappUsername:
              type: string
              description: |
                WhatsApp only. User's WhatsApp username (e.g. `@jane`). Not a
                stable identifier — users can change it. Useful for display, not
                recommended as an identity anchor.
            instagramProfile:
              type: object
              description: Instagram profile data. Only present for Instagram conversations.
              properties:
                isFollower: { type: boolean, nullable: true }
                isFollowing: { type: boolean, nullable: true }
                followerCount: { type: integer, nullable: true }
                isVerified: { type: boolean, nullable: true }
        sentAt:
          type: string
          format: date-time
        isRead:
          type: boolean
    InboxWebhookConversation:
      type: object
      description: The conversation context included in inbox webhook payloads.
      required: [id, platformConversationId, status]
      properties:
        id: { type: string }
        platformConversationId: { type: string }
        participantId: { type: string }
        participantName: { type: string }
        participantUsername: { type: string }
        participantPicture: { type: string }
        status:
          type: string
          enum: [active, archived]
    InboxWebhookAccount:
      type: object
      description: The account context included in inbox webhook payloads.
      required: [id, platform, username]
      properties:
        id:
          type: string
          description: Social account ID
        platform: { type: string }
        username: { type: string }
        displayName: { type: string }
    # ────────────────────────────────────────────────────────────────────────
    WebhookPayloadMessage:
      type: object
      description: Webhook payload for message received events
      required: [id, event, message, conversation, account, timestamp]
      properties:
        id:
          type: string
          description: Stable webhook event ID
        event:
          type: string
          enum: [message.received]
        message:
          type: object
          required: [id, conversationId, platform, platformMessageId, direction, text, attachments, sender, sentAt, isRead]
          properties:
            id:
              type: string
              description: Internal message ID
            conversationId:
              type: string
              description: Internal conversation ID
            platform:
              type: string
              enum: [instagram, facebook, telegram, whatsapp]
            platformMessageId:
              type: string
              description: Platform's message ID
            direction:
              type: string
              enum: [incoming, outgoing]
            text:
              type: string
              nullable: true
              description: Message text content
            attachments:
              type: array
              items:
                type: object
                required: [type, url]
                properties:
                  type:
                    type: string
                    description: Attachment type (image, video, file, sticker, audio)
                  url:
                    type: string
                    description: Attachment URL (may expire for Meta platforms)
                  payload:
                    type: object
                    description: Additional attachment metadata
            sender:
              type: object
              required: [id]
              properties:
                id:
                  type: string
                  description: |
                    Sender's platform identifier. For WhatsApp this is the phone
                    number (without leading `+`) when available, otherwise the
                    `businessScopedUserId`.
                name:
                  type: string
                username:
                  type: string
                picture:
                  type: string
                phoneNumber:
                  type: string
                  nullable: true
                  description: |
                    WhatsApp only. Sender's phone number in E.164 format (with leading `+`).

                    **Nullable during the BSUID rollout (April 2026+).** WhatsApp
                    users who adopt a username can message businesses without
                    exposing a phone number — this field is omitted for them.
                    Match by `businessScopedUserId` instead. See
                    `docs/whatsapp-bsuid-migration.md`.
                businessScopedUserId:
                  type: string
                  description: |
                    WhatsApp only. Business-scoped user ID (BSUID) — Meta's canonical
                    identifier for a WhatsApp user within your business. Present
                    when Meta includes it in the inbound payload (rollout in
                    progress since early April 2026). **Recommended primary identity
                    anchor** going forward; fall back to `phoneNumber` only when
                    this field is absent.
                parentBusinessScopedUserId:
                  type: string
                  description: |
                    WhatsApp only. Parent BSUID for businesses with linked business
                    portfolios. Omitted for standalone portfolios.
                whatsappUsername:
                  type: string
                  description: |
                    WhatsApp only. User's WhatsApp username (e.g. `@jane`). Not a
                    stable identifier — users can change it. Useful for display,
                    not recommended as an identity anchor.
                instagramProfile:
                  type: object
                  description: Instagram profile data for the sender. Only present for Instagram conversations.
                  properties:
                    isFollower:
                      type: boolean
                      nullable: true
                      description: Whether the sender follows your Instagram business account
                    isFollowing:
                      type: boolean
                      nullable: true
                      description: Whether your Instagram business account follows the sender
                    followerCount:
                      type: integer
                      nullable: true
                      description: The sender's follower count on Instagram
                    isVerified:
                      type: boolean
                      nullable: true
                      description: Whether the sender is a verified Instagram user
            sentAt:
              type: string
              format: date-time
            isRead:
              type: boolean
        conversation:
          type: object
          required: [id, platformConversationId, status]
          properties:
            id:
              type: string
            platformConversationId:
              type: string
            participantId:
              type: string
            participantName:
              type: string
            participantUsername:
              type: string
            participantPicture:
              type: string
            status:
              type: string
              enum: [active, archived]
        account:
          type: object
          required: [id, platform, username]
          properties:
            id:
              type: string
              description: Social account ID
            platform:
              type: string
            username:
              type: string
            displayName:
              type: string
        metadata:
          type: object
          nullable: true
          description: Interactive message metadata (present when message is a quick reply tap, postback button tap, or inline keyboard callback)
          properties:
            quickReplyPayload:
              type: string
              description: Payload from a quick reply tap (Facebook/Instagram Messenger).
            postbackPayload:
              type: string
              description: Payload from a postback button tap (Facebook/Instagram Messenger).
            postbackTitle:
              type: string
              description: Title of the tapped postback button (Facebook/Instagram Messenger).
            callbackData:
              type: string
              description: Callback data from an inline keyboard button tap (Telegram).
            interactiveType:
              type: string
              enum: [button_reply, list_reply, nfm_reply]
              description: |
                WhatsApp only. Which kind of interactive reply the user sent:
                `button_reply` (tap on an interactive button), `list_reply` (tap on a
                list row), or `nfm_reply` (a WhatsApp Flow submission).
            interactiveId:
              type: string
              description: |
                WhatsApp only. The `id` of the tapped button or list row, matching the
                `id` you supplied when the message was sent. Not set for Flow responses.
            buttonPayload:
              type: string
              description: |
                WhatsApp only. Payload attached to a tapped template button. Template
                buttons emit a plain `button` webhook (not an interactive reply), so
                `interactiveType` is empty while this field is populated.
            flowResponseJson:
              type: string
              description: |
                WhatsApp only. Raw `nfm_reply.response_json` string returned by a
                Flow submission. Useful if you need the exact wire payload; for
                typed access use `flowResponseData` instead.
            flowResponseData:
              type: object
              additionalProperties: true
              description: |
                WhatsApp only. Parsed Flow response JSON. Populated when
                `flowResponseJson` is valid JSON; otherwise omitted. Keys and
                value types depend on the specific Flow that was submitted.
            referral:
              type: object
              description: |
                WhatsApp only. Click-to-WhatsApp (CTWA) ad attribution. Present
                only on the FIRST inbound message after a user reaches the
                business via a CTWA ad. Forwarded verbatim from Meta's referral
                envelope so it can be replayed to the Conversions API for
                Business Messaging. Attribution window is 7 days from click.
              properties:
                ctwa_clid:
                  type: string
                  description: Meta's GCLID-equivalent click identifier.
                source_id:
                  type: string
                source_type:
                  type: string
                source_url:
                  type: string
                headline:
                  type: string
                body:
                  type: string
                media_type:
                  type: string
                image_url:
                  type: string
                video_url:
                  type: string
                thumbnail_url:
                  type: string
        timestamp:
          type: string
          format: date-time
    WebhookPayloadMessageSent:
      type: object
      description: Webhook payload for message sent events (fired when a message is sent via the API)
      required: [id, event, message, conversation, account, timestamp]
      properties:
        id:
          type: string
          description: Stable webhook event ID
        event:
          type: string
          enum: [message.sent]
        message:
          type: object
          required: [id, conversationId, platform, platformMessageId, direction, text, attachments, sender, sentAt, isRead]
          properties:
            id:
              type: string
              description: Internal message ID
            conversationId:
              type: string
              description: Internal conversation ID
            platform:
              type: string
              enum: [instagram, facebook, telegram, whatsapp]
            platformMessageId:
              type: string
              description: Platform's message ID
            direction:
              type: string
              enum: [incoming, outgoing]
            text:
              type: string
              nullable: true
              description: Message text content
            attachments:
              type: array
              items:
                type: object
                required: [type, url]
                properties:
                  type:
                    type: string
                    description: Attachment type (image, video, file, sticker, audio)
                  url:
                    type: string
                    description: Attachment URL (may expire for Meta platforms)
                  payload:
                    type: object
                    description: Additional attachment metadata
            sender:
              type: object
              required: [id]
              properties:
                id:
                  type: string
                name:
                  type: string
                username:
                  type: string
                picture:
                  type: string
            sentAt:
              type: string
              format: date-time
            isRead:
              type: boolean
        conversation:
          type: object
          required: [id, platformConversationId, status]
          properties:
            id:
              type: string
            platformConversationId:
              type: string
            participantId:
              type: string
            participantName:
              type: string
            participantUsername:
              type: string
            participantPicture:
              type: string
            status:
              type: string
              enum: [active, archived]
        account:
          type: object
          required: [id, platform, username]
          properties:
            id:
              type: string
              description: Social account ID
            platform:
              type: string
            username:
              type: string
            displayName:
              type: string
        timestamp:
          type: string
          format: date-time
    WebhookPayloadMessageEdited:
      type: object
      description: |
        Webhook payload for message.edited events. Fires when the sender
        edits a previously-sent message. Supported platforms: Instagram,
        Facebook Messenger, Telegram. The message object reflects the
        LATEST state; editHistory contains every prior version in order
        (oldest first), so the last entry is the version immediately before
        the current content.
      required: [id, event, message, editHistory, editCount, editedAt, conversation, account, timestamp]
      properties:
        id: { type: string }
        event: { type: string, enum: [message.edited] }
        message:
          $ref: '#/components/schemas/InboxWebhookMessage'
        editHistory:
          type: array
          description: Prior versions of the message, oldest first.
          items:
            type: object
            required: [text, attachments, editedAt]
            properties:
              text:
                type: string
                nullable: true
              attachments:
                type: array
                items:
                  type: object
                  properties:
                    type: { type: string }
                    url: { type: string }
                    payload: { type: object }
              editedAt:
                type: string
                format: date-time
        editCount:
          type: integer
          description: Total number of edits applied to this message.
        editedAt:
          type: string
          format: date-time
          description: When the most recent edit happened.
        conversation:
          $ref: '#/components/schemas/InboxWebhookConversation'
        account:
          $ref: '#/components/schemas/InboxWebhookAccount'
        timestamp: { type: string, format: date-time }
    WebhookPayloadMessageDeleted:
      type: object
      description: |
        Webhook payload for message.deleted events. Fires when the sender
        deletes (unsends) a message. Supported platforms: Instagram (incoming
        unsend) and WhatsApp (when the business deletes an outgoing message
        via the Cloud API).

        The message.text and message.attachments fields retain the content
        that existed before the delete. The Zernio dashboard UI does not show
        this content, but authorized API consumers may access it for
        moderation, compliance, or archival use cases.
      required: [id, event, message, deletedAt, conversation, account, timestamp]
      properties:
        id: { type: string }
        event: { type: string, enum: [message.deleted] }
        message:
          $ref: '#/components/schemas/InboxWebhookMessage'
        deletedAt:
          type: string
          format: date-time
        conversation:
          $ref: '#/components/schemas/InboxWebhookConversation'
        account:
          $ref: '#/components/schemas/InboxWebhookAccount'
        timestamp: { type: string, format: date-time }
    WebhookPayloadMessageDeliveryStatus:
      type: object
      description: |
        Shared payload for message.delivered, message.read, and
        message.failed events. Fires when the platform reports a new
        delivery state for an outgoing message.

        Platform support:
          * message.delivered — WhatsApp, Facebook Messenger.
          * message.read      — WhatsApp, Facebook Messenger, Instagram.
          * message.failed    — WhatsApp only (other platforms don't expose
            per-message failure via webhook).
      required: [id, event, message, statusAt, conversation, account, timestamp]
      properties:
        id: { type: string }
        event:
          type: string
          enum: [message.delivered, message.read, message.failed]
        message:
          $ref: '#/components/schemas/InboxWebhookMessage'
        statusAt:
          type: string
          format: date-time
          description: When the platform reported this status.
        error:
          type: object
          nullable: true
          description: Populated only on message.failed.
          properties:
            code: { type: integer }
            title: { type: string }
            message: { type: string }
        conversation:
          $ref: '#/components/schemas/InboxWebhookConversation'
        account:
          $ref: '#/components/schemas/InboxWebhookAccount'
        timestamp: { type: string, format: date-time }
    WebhookPayloadTest:
      type: object
      description: Webhook payload for test deliveries
      required: [id, event, message, timestamp]
      properties:
        id:
          type: string
          description: Stable webhook event ID
        event:
          type: string
          enum: [webhook.test]
        message:
          type: string
          description: Human-readable test message
        timestamp:
          type: string
          format: date-time
    GeoRestriction:
      type: object
      description: >
        Country-level geo-restriction (allowlist). When set, the post is only visible to users in the
        specified countries. Supported on Facebook (feed posts, videos, reels), X/Twitter (media-level
        restriction), and LinkedIn (organization pages only, min 300 targeted followers). Ignored on
        unsupported platforms. Stories (Facebook, Instagram) do not support geo-restriction.
      properties:
        countries:
          type: array
          minItems: 1
          maxItems: 25
          items:
            type: string
            pattern: '^[A-Z]{2}$'
            example: US
          description: >
            ISO 3166-1 alpha-2 country codes (uppercase). Only users in these countries can see the post.
            Maximum 25 countries per post. Example: ["US", "CA", "GB", "ES"].
          example: ["US", "CA", "GB"]
      required:
        - countries

    MediaItem:
      type: object
      description: Media referenced in posts. URLs must be publicly reachable over HTTPS. Use POST /v1/media/presign for uploads up to 5GB. Zernio auto-compresses images and videos that exceed platform limits (videos over 200 MB may not be compressed).
      properties:
        type:
          type: string
          enum: [image, video, gif, document]
        url:
          type: string
          format: uri
        title:
          type: string
          description: Optional title for the media item. Used as the document title for LinkedIn PDF/carousel posts. If omitted, falls back to the post title, then the filename.
        filename:
          type: string
        size:
          type: integer
          description: Optional file size in bytes
        mimeType:
          type: string
          description: Optional MIME type (e.g. image/jpeg, video/mp4)
        thumbnail:
          type: string
          format: uri
          description: Optional custom thumbnail/cover image URL for videos. Supported for Facebook video posts, Facebook Reels, and regular video uploads. Max 10MB, JPG/PNG recommended.
        instagramThumbnail:
          type: string
          format: uri
          description: "Custom cover image URL for Instagram Reels. Can also be set via platformSpecificData.instagramThumbnail or platformSpecificData.reelCover. Resolution order: this field > platformSpecificData.instagramThumbnail > platformSpecificData.reelCover > platformSpecificData.thumbnailUrl (legacy)."
        tiktokProcessed:
          type: boolean
          description: Internal flag indicating the image was resized for TikTok
    PlatformTarget:
      type: object
      properties:
        platform:
          type: string
          example: twitter
          description: "Supported values: twitter, threads, instagram, youtube, facebook, linkedin, pinterest, reddit, tiktok, bluesky, googlebusiness, telegram"
        accountId:
          oneOf:
            - type: string
            - $ref: '#/components/schemas/SocialAccount'
        customContent:
          type: string
          description: Platform-specific text override. When set, this content is used instead of the top-level post content for this platform. Useful for tailoring captions per platform (e.g. keeping tweets under 280 characters).
        customMedia:
          type: array
          items:
            $ref: '#/components/schemas/MediaItem'
        scheduledFor:
          type: string
          format: date-time
          description: Optional per-platform scheduled time override (uses post.scheduledFor when omitted)
        platformSpecificData:
          description: Platform-specific overrides and options.
          oneOf:
            - $ref: '#/components/schemas/TwitterPlatformData'
            - $ref: '#/components/schemas/ThreadsPlatformData'
            - $ref: '#/components/schemas/FacebookPlatformData'
            - $ref: '#/components/schemas/InstagramPlatformData'
            - $ref: '#/components/schemas/LinkedInPlatformData'
            - $ref: '#/components/schemas/PinterestPlatformData'
            - $ref: '#/components/schemas/YouTubePlatformData'
            - $ref: '#/components/schemas/GoogleBusinessPlatformData'
            - $ref: '#/components/schemas/TikTokPlatformData'
            - $ref: '#/components/schemas/TelegramPlatformData'
            - $ref: '#/components/schemas/SnapchatPlatformData'
            - $ref: '#/components/schemas/RedditPlatformData'
            - $ref: '#/components/schemas/BlueskyPlatformData'
            - $ref: '#/components/schemas/DiscordPlatformData'
          additionalProperties: true
        status:
          type: string
          example: pending
          description: "Platform-specific status: pending, publishing, published, failed"
        platformPostId:
          type: string
          description: The native post ID on the platform (populated after successful publish)
          example: "1234567890123456789"
        platformPostUrl:
          type: string
          format: uri
          description: Public URL of the published post. Included in the response for immediate posts; for scheduled posts, fetch via GET /v1/posts/{postId} after publish time.
          example: "https://twitter.com/acmecorp/status/1234567890123456789"
        publishedAt:
          type: string
          format: date-time
          description: Timestamp when the post was published to this platform
        errorMessage:
          type: string
          description: Human-readable error message when status is failed. Contains platform-specific error details explaining why the publish failed.
        errorCategory:
          type: string
          enum: [auth_expired, user_content, user_abuse, account_issue, platform_rejected, platform_error, system_error, unknown]
          description: "Error category for programmatic handling: auth_expired (token expired/revoked), user_content (wrong format/too long), user_abuse (rate limits/spam), account_issue (config problems), platform_rejected (policy violation), platform_error (5xx/maintenance), system_error (Zernio infra), unknown"
        errorSource:
          type: string
          enum: [user, platform, system]
          description: "Who caused the error: user (fix content/reconnect), platform (outage/API change), system (Zernio issue, rare)"
    Post:
      type: object
      properties:
        _id: { type: string }
        userId:
          oneOf:
            - type: string
            - $ref: '#/components/schemas/User'
        title:
          type: string
          description: |
            YouTube: title must be ≤ 100 characters.
        content: { type: string }
        mediaItems:
          type: array
          items: { $ref: '#/components/schemas/MediaItem' }
        platforms:
          type: array
          items: { $ref: '#/components/schemas/PlatformTarget' }
        scheduledFor: { type: string, format: date-time }
        timezone: { type: string }
        status: { type: string, enum: [draft, scheduled, publishing, published, failed, partial] }
        tags:
          type: array
          description: "YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates removed."
          items: { type: string }
        hashtags:
          type: array
          items: { type: string }
        mentions:
          type: array
          description: "Stored for reference only. This field does NOT automatically create @mentions when publishing. For LinkedIn @mentions, use the /v1/accounts/{accountId}/linkedin-mentions endpoint to resolve profile URLs to URNs, then embed the returned mentionFormat directly in the post content field."
          items: { type: string }
        visibility: { type: string, enum: [public, private, unlisted] }
        metadata:
          type: object
          additionalProperties: true
        recycling:
          $ref: '#/components/schemas/RecyclingState'
        recycledFromPostId:
          type: string
          description: ID of the original post if this post was created via recycling
        queuedFromProfile:
          type: string
          description: Profile ID if the post was scheduled via the queue
        queueId:
          type: string
          description: Queue ID if the post was scheduled via a specific queue
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }

    RecyclingConfig:
      type: object
      description: |
        Configure automatic post recycling (reposting at regular intervals).
        After the post is published, the system creates new scheduled copies at the
        specified interval until expiration conditions are met. Supports weekly or
        monthly intervals. Maximum 10 active recycling posts per account.
        YouTube and TikTok platforms are excluded from recycling.
        Content variations are recommended for Twitter and Pinterest to avoid duplicate flags.
      properties:
        enabled:
          type: boolean
          default: true
          description: Set to false to disable recycling on this post
        gap:
          type: integer
          minimum: 1
          description: Number of interval units between each repost. Required when enabling recycling.
          example: 2
        gapFreq:
          type: string
          enum: [week, month]
          default: month
          description: Interval unit for the gap. Defaults to 'month'.
        startDate:
          type: string
          format: date-time
          description: When to start the recycling cycle. Defaults to the post's scheduledFor date.
        expireCount:
          type: integer
          minimum: 1
          description: Stop recycling after this many copies have been created
          example: 5
        expireDate:
          type: string
          format: date-time
          description: Stop recycling after this date, regardless of count
        contentVariations:
          type: array
          items:
            type: string
          maxItems: 20
          description: |
            Array of content variations for recycled copies. On each recycle, the next
            variation is used in round-robin order. Recommended for Twitter and Pinterest
            to avoid duplicate content flags. If omitted, the original post content is
            used for all recycled copies. Send an empty array [] to clear existing
            variations. Must have 2+ entries when setting variations. Platform-level
            customContent still overrides the base content per platform.
    RecyclingState:
      type: object
      description: Current recycling configuration and state on a post
      properties:
        enabled:
          type: boolean
          description: Whether recycling is currently active
        gap:
          type: integer
          description: Number of interval units between reposts
        gapFreq:
          type: string
          enum: [week, month]
          description: Interval unit (week or month)
        startDate: { type: string, format: date-time }
        expireCount: { type: integer }
        expireDate: { type: string, format: date-time }
        contentVariations:
          type: array
          items:
            type: string
          description: Content variations for recycled copies (if configured)
        contentVariationIndex:
          type: integer
          description: Current position in the content variations rotation (read-only)
        recycleCount:
          type: integer
          description: How many recycled copies have been created so far (read-only)
        nextRecycleAt:
          type: string
          format: date-time
          description: When the next recycled copy will be created (read-only)
        lastRecycledAt:
          type: string
          format: date-time
          description: When the last recycled copy was created (read-only)

    TwitterPlatformData:
      type: object
      properties:
        replyToTweetId:
          type: string
          description: ID of an existing tweet to reply to. The published tweet will appear as a reply in that tweet's thread. For threads, only the first tweet replies to the target; subsequent tweets chain normally.
        replySettings:
          type: string
          enum: [following, mentionedUsers, subscribers, verified]
          description: Controls who can reply to the tweet. "following" allows only people you follow, "mentionedUsers" allows only mentioned users, "subscribers" allows only subscribers, "verified" allows only verified users. Omit for default (everyone can reply). For threads, applies to the first tweet only. Cannot be combined with replyToTweetId.
        threadItems:
          type: array
          description: >
            Complete sequence of tweets in a thread. The first item becomes the root tweet,
            subsequent items are chained as replies. When threadItems is provided, the top-level
            content field is used only for display and search purposes, it is NOT published.
            You must include your first tweet as threadItems[0].
          items:
            type: object
            properties:
              content: { type: string }
              mediaItems:
                type: array
                items: { $ref: '#/components/schemas/MediaItem' }
        poll:
          type: object
          description: Create a poll with this tweet. Mutually exclusive with media attachments and threads.
          properties:
            options:
              type: array
              minItems: 2
              maxItems: 4
              items:
                type: string
                minLength: 1
                maxLength: 25
              description: Poll options (2-4 choices, max 25 characters each)
            duration_minutes:
              type: integer
              minimum: 5
              maximum: 10080
              description: Poll duration in minutes (5 min to 7 days)
          required:
            - options
            - duration_minutes
        longVideo:
          type: boolean
          default: false
          description: Enable long video uploads (over 140 seconds) using amplify_video media category. Requires the connected X account to have an active X Premium subscription. When true, videos are uploaded with the amplify_video category which supports longer durations (up to 10 minutes via API). When false or omitted, the standard tweet_video category is used (140 second limit). Note that not all Premium accounts have API long-video access, as X may require separate allowlisting.
        geoRestriction:
          $ref: '#/components/schemas/GeoRestriction'
      description: >
        X (Twitter) geo-restriction applies at the media level. Media in geo-restricted tweets will be
        hidden for users outside the specified countries; the tweet text itself remains visible globally.
        Requires media to be attached (ignored for text-only tweets).

    ThreadsPlatformData:
      type: object
      properties:
        topic_tag:
          type: string
          minLength: 1
          maxLength: 50
          description: Topic tag for post categorization and discoverability on Threads. Must be 1-50 characters, cannot contain periods (.) or ampersands (&). Overrides auto-extraction from content hashtags when provided.
        threadItems:
          type: array
          description: >
            Complete sequence of posts in a Threads thread. The first item becomes the root post,
            subsequent items are chained as replies. When threadItems is provided, the top-level
            content field is used only for display and search purposes, it is NOT published.
            You must include your first post as threadItems[0].
          items:
            type: object
            properties:
              content: { type: string }
              mediaItems:
                type: array
                items: { $ref: '#/components/schemas/MediaItem' }
      description: Up to 10 images per carousel (no videos). Videos must be H.264/AAC MP4, max 5 min. Images JPEG/PNG, max 8 MB. Use threadItems for reply chains.

    FacebookPlatformData:
      type: object
      properties:
        draft:
          type: boolean
          description: When true, creates the post as an unpublished draft visible in Facebook Publishing Tools instead of publishing immediately. Supported for feed posts (text, link, image, video) and reels. Not supported for stories. Drafts expire after ~30 days.
          default: false
        contentType:
          type: string
          enum: [story, reel]
          description: Set to 'story' for Page Stories (24h ephemeral) or 'reel' for Reels (short vertical video). Defaults to feed post if omitted.
        title:
          type: string
          description: Reel title (only for contentType=reel). Separate from the caption/content field.
        firstComment:
          type: string
          description: Optional first comment to post immediately after publishing (feed posts and reels, not stories). Skipped when draft is true.
        pageId:
          type: string
          description: Target Facebook Page ID for multi-page posting. If omitted, uses the default page. Use GET /v1/accounts/{id}/facebook-page to list pages.
        geoRestriction:
          $ref: '#/components/schemas/GeoRestriction'
      description: >
        Feed posts support up to 10 images (no mixed video+image). Stories require single media (24h, no captions).
        Reels require single vertical video (9:16, 3-60s). Geo-restriction is a hard visibility restriction:
        users outside the specified countries cannot see the post. Not supported for stories.

    InstagramPlatformData:
      type: object
      properties:
        contentType:
          type: string
          enum: [story]
          description: Set to 'story' to publish as a Story. Default posts become Reels or feed depending on media.
        shareToFeed:
          type: boolean
          default: true
          description: For Reels only. When true (default), the Reel appears on both the Reels tab and your main profile feed. Set to false to post to the Reels tab only.
        collaborators:
          type: array
          items: { type: string }
          description: Up to 3 Instagram usernames to invite as collaborators (feed/Reels only)
        firstComment:
          type: string
          description: Optional first comment to add after the post is created (not applied to Stories)
        trialParams:
          type: object
          description: Trial Reels configuration. Trial reels are shared to non-followers first and can later be graduated to regular reels manually or automatically based on performance. Only applies to Reels.
          properties:
            graduationStrategy:
              type: string
              enum: [MANUAL, SS_PERFORMANCE]
              description: "MANUAL (graduate from Instagram app) or SS_PERFORMANCE (auto-graduate if performs well with non-followers)"
        userTags:
          type: array
          description: Tag Instagram users in photos by username and position. Not supported for stories or videos. For carousels, use mediaIndex to target specific slides (defaults to 0). Tags on video items are silently skipped.
          items:
            type: object
            required: [username, x, y]
            properties:
              username:
                type: string
                description: Instagram username (@ symbol is optional and will be removed automatically)
                example: friend_username
              x:
                type: number
                minimum: 0
                maximum: 1
                description: X coordinate position from left edge (0.0 = left, 0.5 = center, 1.0 = right)
                example: 0.5
              y:
                type: number
                minimum: 0
                maximum: 1
                description: Y coordinate position from top edge (0.0 = top, 0.5 = center, 1.0 = bottom)
                example: 0.5
              mediaIndex:
                type: integer
                minimum: 0
                description: Zero-based index of the carousel item to tag. Defaults to 0. Tags on video items or out-of-range indices are ignored.
                example: 0
        audioName:
          type: string
          description: Custom name for original audio in Reels. Replaces the default "Original Audio" label. Can only be set once.
          example: "My Podcast Intro"
        thumbOffset:
          type: integer
          minimum: 0
          description: Millisecond offset from video start for the Reel cover frame. Ignored when instagramThumbnail or reelCover is provided. Defaults to 0.
          example: 5000
        instagramThumbnail:
          type: string
          format: uri
          description: Custom cover image URL for Instagram Reels (JPG or PNG, publicly accessible). Overrides thumbOffset when provided. Also accepted as reelCover (alias).
        reelCover:
          type: string
          format: uri
          description: Alias for instagramThumbnail. If both are provided, instagramThumbnail takes priority.
      description: Feed aspect ratio 0.8-1.91, carousels up to 10 items, stories require media (no captions). User tag coordinates 0.0-1.0 from top-left. Images over 8 MB and videos over platform limits are auto-compressed.

    LinkedInPlatformData:
      type: object
      properties:
        documentTitle:
          type: string
          description: Title displayed on LinkedIn document (PDF/carousel) posts. Required by LinkedIn for document posts. If omitted, falls back to the media item title, then the filename.
        organizationUrn:
          type: string
          description: Target LinkedIn Organization URN (e.g. "urn:li:organization:123456789"). If omitted, uses the default org. Use GET /v1/accounts/{id}/linkedin-organizations to list orgs.
        firstComment:
          type: string
          description: Optional first comment to add after the post is created
        disableLinkPreview:
          type: boolean
          description: Set to true to disable automatic link previews for URLs in the post content (default is false)
        geoRestriction:
          $ref: '#/components/schemas/GeoRestriction'
      description: >
        Up to 20 images, no multi-video. Single PDF supported (max 100MB). Link previews auto-generated
        when no media attached. Use organizationUrn for multi-org posting. Geo-restriction only works for
        organization pages (not personal profiles) and requires the targeted audience to exceed 300 followers.

    PinterestPlatformData:
      type: object
      properties:
        title:
          type: string
          maxLength: 100
          description: Pin title. Defaults to first line of content or "Pin". Must be ≤ 100 characters.
        boardId:
          type: string
          description: Target Pinterest board ID. If omitted, the first available board is used.
        link:
          type: string
          format: uri
          description: Destination link (pin URL)
        coverImageUrl:
          type: string
          format: uri
          description: Optional cover image for video pins
        coverImageKeyFrameTime:
          type: integer
          description: Optional key frame time in seconds for derived video cover

    YouTubePlatformData:
      type: object
      properties:
        title:
          type: string
          maxLength: 100
          description: Video title. Defaults to first line of content or "Untitled Video". Must be ≤ 100 characters.
        visibility:
          type: string
          enum: [public, private, unlisted]
          default: public
          description: "Video visibility: public (default, anyone can watch), unlisted (link only), private (invite only)"
        madeForKids:
          type: boolean
          default: false
          description: COPPA compliance flag. Set true for child-directed content (restricts comments, notifications, ad targeting). Defaults to false. YouTube may block views if not explicitly set.
        firstComment:
          type: string
          maxLength: 10000
          description: Optional first comment to post immediately after video upload. Up to 10,000 characters (YouTube's comment limit).
        containsSyntheticMedia:
          type: boolean
          default: false
          description: AI-generated content disclosure. Set true if the video contains synthetic content that could be mistaken for real. YouTube may add a label.
        categoryId:
          type: string
          default: '22'
          description: "YouTube video category ID. Defaults to 22 (People & Blogs). Common: 1 (Film), 2 (Autos), 10 (Music), 15 (Pets), 17 (Sports), 20 (Gaming), 23 (Comedy), 24 (Entertainment), 25 (News), 26 (Howto), 27 (Education), 28 (Science & Tech)."
        playlistId:
          type: string
          description: "Optional YouTube playlist ID to add the video to after upload (e.g. 'PLxxxxxxxxxxxxx'). Use GET /v1/accounts/{id}/youtube-playlists to list available playlists. Works for both immediate and scheduled uploads. Quota cost: 50 YouTube API units per call."
      description: Videos under 3 min auto-detected as Shorts. Custom thumbnails for regular videos only. Scheduled videos are uploaded immediately with the specified visibility.

    GoogleBusinessPlatformData:
      type: object
      properties:
        locationId:
          type: string
          description: Target GBP location ID (e.g. "locations/123456789"). If omitted, uses the default location. Use GET /v1/accounts/{id}/gmb-locations to list locations.
        languageCode:
          type: string
          description: BCP 47 language code (e.g. "en", "de", "es"). Auto-detected if omitted. Set explicitly for short or mixed-language posts.
          example: "de"
        topicType:
          type: string
          enum: [STANDARD, EVENT, OFFER]
          default: STANDARD
          description: "Post type. STANDARD is a regular update. EVENT requires the event object. OFFER requires the offer object. Defaults to STANDARD if omitted."
        callToAction:
          type: object
          description: Optional call-to-action button displayed on the post
          properties:
            type:
              type: string
              enum: [LEARN_MORE, BOOK, ORDER, SHOP, SIGN_UP, CALL]
              description: "Button action type: LEARN_MORE, BOOK, ORDER, SHOP, SIGN_UP, CALL"
            url:
              type: string
              format: uri
              description: Destination URL for the CTA button (required when callToAction is provided)
          required: [type, url]
        event:
          type: object
          description: "Event details. Required when topicType is EVENT. Google returns 400 if omitted for EVENT posts."
          properties:
            title:
              type: string
              description: Event name (displayed as the event heading on Google Search and Maps)
              example: "Grand Opening Weekend"
            schedule:
              type: object
              description: "Event date/time range. Uses Google's date format (NOT ISO 8601)."
              properties:
                startDate:
                  type: object
                  description: "Event start date as { year, month, day }"
                  properties:
                    year:
                      type: integer
                      example: 2026
                    month:
                      type: integer
                      minimum: 1
                      maximum: 12
                      example: 5
                    day:
                      type: integer
                      minimum: 1
                      maximum: 31
                      example: 15
                  required: [year, month, day]
                startTime:
                  type: object
                  description: "Optional start time as { hours, minutes } in 24h format"
                  properties:
                    hours:
                      type: integer
                      minimum: 0
                      maximum: 23
                      example: 9
                    minutes:
                      type: integer
                      minimum: 0
                      maximum: 59
                      example: 0
                endDate:
                  type: object
                  description: "Event end date as { year, month, day }"
                  properties:
                    year:
                      type: integer
                      example: 2026
                    month:
                      type: integer
                      minimum: 1
                      maximum: 12
                      example: 5
                    day:
                      type: integer
                      minimum: 1
                      maximum: 31
                      example: 16
                  required: [year, month, day]
                endTime:
                  type: object
                  description: "Optional end time as { hours, minutes } in 24h format"
                  properties:
                    hours:
                      type: integer
                      minimum: 0
                      maximum: 23
                      example: 17
                    minutes:
                      type: integer
                      minimum: 0
                      maximum: 59
                      example: 0
              required: [startDate, endDate]
          required: [title, schedule]
        offer:
          type: object
          description: "Offer details. Required when topicType is OFFER. All fields are optional per Google's API, but at least one is recommended."
          properties:
            offerType:
              type: string
              enum: [OFFER, BUY_ONE_GET_ONE]
              description: Type of offer
            redeemOnlineUrl:
              type: string
              format: uri
              description: URL where the offer can be redeemed online
            termsConditions:
              type: string
              description: Terms and conditions for the offer
            couponCode:
              type: string
              description: Coupon code for the offer
              example: "SAVE20"
      description: "Text and single image only (no videos). Supports STANDARD, EVENT, OFFER, and ALERT post types. Posts appear on GBP, Google Search, and Maps. Use locationId for multi-location posting. Schedule dates accept both ISO 8601 strings (e.g. '2026-04-15T09:00:00Z') and Google's native {year, month, day} objects."

    TikTokPlatformData:
      type: object
      description: |
        Photo carousels up to 35 images. Video titles up to 2200 chars, photo titles truncated to 90 chars.
        privacyLevel must match creator_info options. Both camelCase and snake_case accepted.

        Creator Inbox (draft mode): Set draft: true to send content to the TikTok Creator Inbox
        instead of publishing immediately. The creator receives an inbox notification and completes
        the post using TikTok's editing flow. This maps to TikTok's post_mode: "MEDIA_UPLOAD" internally.

        Important: The field publish_type is NOT supported. Use draft: true for Creator Inbox flow.

        Photo drafts use the /v2/post/publish/content/init/ endpoint with post_mode: "MEDIA_UPLOAD".
        Video drafts use the dedicated /v2/post/publish/inbox/video/init/ endpoint.

        When draft: true, the video.upload scope is required. When draft is false or omitted
        (direct post), the video.publish scope is required. For Creator Inbox, TikTok app version
        must be 31.8 or higher.
      properties:
        draft:
          type: boolean
          description: |
            When true, sends the post to the TikTok Creator Inbox as a draft instead of publishing
            immediately. The creator receives an inbox notification to complete posting via TikTok's
            editing flow. Maps to TikTok API post_mode: "MEDIA_UPLOAD" (photos) or the dedicated
            inbox endpoint (videos). When false or omitted, publishes directly via post_mode: "DIRECT_POST".
            Note: publish_type is not a supported field. Use this field instead.
        privacyLevel:
          type: string
          description: One of the values returned by the TikTok creator info API for the account
        allowComment:
          type: boolean
          description: Allow comments on the post
        allowDuet:
          type: boolean
          description: Allow duets (required for video posts)
        allowStitch:
          type: boolean
          description: Allow stitches (required for video posts)
        commercialContentType:
          type: string
          enum: [none, brand_organic, brand_content]
          description: Type of commercial content disclosure
        brandPartnerPromote:
          type: boolean
          description: Whether the post promotes a brand partner
        isBrandOrganicPost:
          type: boolean
          description: Whether the post is a brand organic post
        contentPreviewConfirmed:
          type: boolean
          description: User has confirmed they previewed the content
        expressConsentGiven:
          type: boolean
          description: User has given express consent for posting
        mediaType:
          type: string
          enum: [video, photo]
          description: Optional override. Defaults based on provided media items.
        videoCoverTimestampMs:
          type: integer
          description: Optional for video posts. Timestamp in milliseconds to select which frame to use as thumbnail (defaults to 1000ms/1 second). Ignored when videoCoverImageUrl is provided.
          minimum: 0
        videoCoverImageUrl:
          type: string
          format: uri
          description: Optional for video posts. URL of a custom thumbnail image (JPG, PNG, or WebP, max 20MB). The image is stitched as a single frame at the start of the video and used as the cover. Overrides videoCoverTimestampMs when provided.
        photoCoverIndex:
          type: integer
          description: Optional for photo carousels. Index of image to use as cover, 0-based (defaults to 0/first image).
          minimum: 0
        autoAddMusic:
          type: boolean
          description: When true, TikTok may add recommended music (photos only)
        videoMadeWithAi:
          type: boolean
          description: Set true to disclose AI-generated content
        description:
          type: string
          maxLength: 4000
          description: Optional long-form description for photo posts (max 4000 chars). Recommended when content exceeds 90 chars, as photo titles are auto-truncated.

    TelegramPlatformData:
      type: object
      properties:
        parseMode:
          type: string
          enum: [HTML, Markdown, MarkdownV2]
          description: Text formatting mode for the message (default is HTML)
        disableWebPagePreview:
          type: boolean
          description: Disable link preview generation for URLs in the message
        disableNotification:
          type: boolean
          description: Send the message silently (users will receive notification without sound)
        protectContent:
          type: boolean
          description: Protect message content from forwarding and saving
      description: Text, images (up to 10), videos (up to 10), and mixed media albums. Captions up to 1024 chars for media, 4096 for text-only.

    SnapchatPlatformData:
      type: object
      properties:
        contentType:
          type: string
          enum: [story, saved_story, spotlight]
          default: story
          description: "Content type: story (ephemeral 24h, default), saved_story (permanent on Public Profile), spotlight (video feed)"
      description: "Requires a Public Profile. Single media item only. Content types: story (ephemeral 24h), saved_story (permanent, title max 45 chars), spotlight (video, max 160 chars)."

    RedditPlatformData:
      type: object
      properties:
        subreddit:
          type: string
          description: Target subreddit name (without "r/" prefix). Overrides the default. Use GET /v1/accounts/{id}/reddit-subreddits to list options.
          example: socialmedia
        title:
          type: string
          maxLength: 300
          description: Post title. Defaults to the first line of content, truncated to 300 characters.
        url:
          type: string
          format: uri
          description: URL for link posts. If provided (and forceSelf is not true), creates a link post instead of a text post.
        forceSelf:
          type: boolean
          description: When true, creates a text/self post even when a URL or media is provided.
        flairId:
          type: string
          description: Flair ID for the post. Required by some subreddits. Use GET /v1/accounts/{id}/reddit-flairs?subreddit=name to list flairs.
          example: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
        nativeVideo:
          type: boolean
          description: >
            Controls Reddit's native video upload flow. When true (default for video mediaItems),
            the video is uploaded to Reddit's CDN and submitted with kind=video so it renders as
            an embedded Reddit video player. Reddit transcodes server-side (1080p/30fps cap). Set
            to false to fall back to a legacy link post. If the subreddit blocks video posts, the
            upload falls back to a link post automatically.
          default: true
        videogif:
          type: boolean
          description: When true (and nativeVideo is active), submits the video as a silent videogif (kind=videogif). Use for short looping clips without audio.
        videoPosterUrl:
          type: string
          format: uri
          description: Optional poster/thumbnail image URL for native video posts. If omitted, the first frame of the video is extracted and used automatically.
      description: Posts are either link (with URL/media), native video (via nativeVideo), or self (text-only). Use forceSelf to override. Subreddit defaults to the account's configured one. Some subreddits require a flair.

    BlueskyPlatformData:
      type: object
      properties:
        threadItems:
          type: array
          description: >
            Complete sequence of posts in a Bluesky thread. The first item becomes the root post,
            subsequent items are chained as replies. When threadItems is provided, the top-level
            content field is used only for display and search purposes, it is NOT published.
            You must include your first post as threadItems[0].
          items:
            type: object
            properties:
              content:
                type: string
              mediaItems:
                type: array
                items:
                  $ref: '#/components/schemas/MediaItem'
      description: |
        Bluesky post settings. Supports text posts with up to 4 images or a single video. threadItems creates a reply chain (Bluesky thread). Images exceeding 1MB are automatically compressed. Alt text supported via mediaItem properties.

    DiscordPlatformData:
      type: object
      required: [channelId]
      properties:
        channelId:
          type: string
          description: Target channel snowflake ID. Determines which channel in the connected server receives the message.
          example: "1234567890123456789"
        embeds:
          type: array
          description: Up to 10 Discord embed objects (combined max 6,000 characters across all embeds). Sent alongside or instead of plain-text content.
          maxItems: 10
          items:
            type: object
            properties:
              title:
                type: string
                description: Embed title (max 256 chars)
              description:
                type: string
                description: Embed body text (max 4,096 chars)
              url:
                type: string
                description: URL the title links to
              color:
                type: integer
                description: Embed accent color as decimal integer (e.g. 5814783 for blue). Convert hex to decimal.
              image:
                type: object
                properties:
                  url: { type: string }
              thumbnail:
                type: object
                properties:
                  url: { type: string }
              footer:
                type: object
                properties:
                  text: { type: string, description: Footer text (max 2,048 chars) }
                  icon_url: { type: string }
              author:
                type: object
                properties:
                  name: { type: string, description: Author name (max 256 chars) }
                  url: { type: string }
                  icon_url: { type: string }
              fields:
                type: array
                description: Up to 25 fields per embed
                maxItems: 25
                items:
                  type: object
                  required: [name, value]
                  properties:
                    name: { type: string, description: Field name (max 256 chars) }
                    value: { type: string, description: Field value (max 1,024 chars) }
                    inline: { type: boolean, description: Display fields side-by-side }
        poll:
          type: object
          description: Native Discord poll. Cannot be combined with media attachments in the same message.
          properties:
            question:
              type: object
              required: [text]
              properties:
                text: { type: string, description: Poll question (max 300 chars) }
            answers:
              type: array
              description: 1-10 answer options
              maxItems: 10
              items:
                type: object
                properties:
                  poll_media:
                    type: object
                    properties:
                      text: { type: string, description: Answer text }
            duration:
              type: integer
              description: Poll duration in hours (1-768). Default 24.
              minimum: 1
              maximum: 768
            allow_multiselect:
              type: boolean
              description: Allow users to select multiple answers. Default false.
        crosspost:
          type: boolean
          description: Auto-crosspost to every server following this announcement channel (type 5). No-op for regular text channels.
        forumThreadName:
          type: string
          description: Thread title for forum channel posts (type 15). Required when posting to a forum channel.
        forumAppliedTags:
          type: array
          description: Tag snowflake IDs to apply to forum posts. Max 5 tags.
          maxItems: 5
          items:
            type: string
        threadFromMessage:
          type: object
          description: Create a follow-up thread under the published message.
          properties:
            name:
              type: string
              description: Thread name (1-100 chars)
            autoArchiveDuration:
              type: integer
              description: Auto-archive after inactivity (minutes)
              enum: [60, 1440, 4320, 10080]
            rateLimitPerUser:
              type: integer
              description: Slow-mode duration in seconds (0-21600)
              minimum: 0
              maximum: 21600
        tts:
          type: boolean
          description: Send as text-to-speech message. Discord reads the message aloud in the channel.
        webhookUsername:
          type: string
          description: Override the webhook display name for this post only (1-80 chars). Falls back to the account-level default set via PATCH /v1/connect/discord.
        webhookAvatarUrl:
          type: string
          description: Override the webhook avatar URL for this post only. Falls back to the account-level default.
      description: |
        Discord message settings. Supports plain text (2,000 chars), rich embeds (up to 10), native polls, forum posts, threads, and announcement crossposts. Media attachments support images (JPEG, PNG, GIF, WebP), videos (MP4), and documents (up to 10 files, 25 MB each). Webhook identity (username + avatar) can be customized per-account via PATCH /v1/connect/discord or per-post via webhookUsername/webhookAvatarUrl.

    QueueSlot:
      type: object
      properties:
        dayOfWeek:
          type: integer
          description: Day of week (0=Sunday, 6=Saturday)
          minimum: 0
          maximum: 6
        time:
          type: string
          description: Time in HH:mm format (24-hour)
          pattern: '^([0-1][0-9]|2[0-3]):[0-5][0-9]$'
    QueueSchedule:
      type: object
      properties:
        _id:
          type: string
          description: Unique queue identifier
        profileId:
          type: string
          description: Profile ID this queue belongs to
        name:
          type: string
          description: Queue name (e.g., "Morning Posts", "Evening Content")
        timezone:
          type: string
          description: IANA timezone (e.g., America/New_York)
        slots:
          type: array
          items:
            $ref: '#/components/schemas/QueueSlot'
        active:
          type: boolean
          description: Whether the queue is active
        isDefault:
          type: boolean
          description: Whether this is the default queue for the profile (used when no queueId specified)
        createdAt:
          type: string
          format: date-time
        updatedAt:
          type: string
          format: date-time
    Pagination:
      type: object
      properties:
        page: { type: integer }
        limit: { type: integer }
        total: { type: integer }
        pages: { type: integer }
    Profile:
      type: object
      properties:
        _id: { type: string }
        userId: { type: string }
        name: { type: string }
        description: { type: string }
        color: { type: string }
        isDefault: { type: boolean }
        isOverLimit:
          type: boolean
          description: Only present when includeOverLimit=true. Indicates if this profile exceeds the plan limit.
        createdAt: { type: string, format: date-time }
    SocialAccount:
      type: object
      properties:
        _id: { type: string }
        platform:
          type: string
          enum: [tiktok, instagram, facebook, youtube, linkedin, twitter, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, discord, whatsapp, linkedinads, metaads, pinterestads, tiktokads, xads, googleads]
        profileId:
          oneOf:
            - type: string
            - $ref: '#/components/schemas/Profile'
        username: { type: string }
        displayName: { type: string }
        profilePicture:
          type: string
          nullable: true
          description: URL to the account's profile picture on the platform. May be null if the platform does not provide one.
        profileUrl:
          type: string
          description: Full profile URL for the connected account on its platform.
        isActive: { type: boolean }
        followersCount:
          type: number
          description: Follower count (only included if user has analytics add-on)
        followersLastUpdated:
          type: string
          format: date-time
          description: Last time follower count was updated (only included if user has analytics add-on)
        parentAccountId:
          type: string
          nullable: true
          description: |
            Reference to the parent posting SocialAccount. Set for ads accounts that share
            or derive from a posting account's OAuth token. null for standalone ads (Google Ads)
            and all posting accounts.
        enabled:
          type: boolean
          description: |
            Whether the user explicitly activated this account. false means the account was
            created as a side effect (e.g., posting account auto-created when user connected
            ads first). Posting UI and scheduler ignore accounts with enabled: false.
        metadata:
          type: object
          description: |
            Platform-specific metadata. Fields vary by platform. For WhatsApp accounts, includes:
            - qualityRating: Phone number quality rating from Meta (GREEN, YELLOW, RED, or UNKNOWN)
            - nameStatus: Display name review status (APPROVED, PENDING_REVIEW, DECLINED, or NONE). Messages cannot be sent until the display name is approved by Meta.
            - messagingLimitTier: Maximum unique business-initiated conversations per 24h rolling window (TIER_250, TIER_1K, TIER_10K, TIER_100K, or TIER_UNLIMITED). Scales automatically as quality rating improves.
            - verifiedName: Meta-verified business display name
            - displayPhoneNumber: Formatted phone number (e.g., "+1 555-123-4567")
            - wabaId: WhatsApp Business Account ID
            - phoneNumberId: Meta phone number ID
    AccountWithFollowerStats:
      allOf:
        - $ref: '#/components/schemas/SocialAccount'
        - type: object
          properties:
            currentFollowers: { type: number, description: Current follower count }
            lastUpdated: { type: string, format: date-time }
            growth: { type: number, description: Follower change over period }
            growthPercentage: { type: number, description: Percentage growth }
            dataPoints: { type: number, description: Number of historical snapshots }
            accountStats:
              type: object
              description: |
                Platform-specific account stats from the latest daily snapshot.
                Fields vary by platform. Only present if metadata has been captured.
              properties:
                followingCount: { type: number, description: Number of accounts being followed }
                mediaCount: { type: number, description: Total media posts (Instagram) }
                videoCount: { type: number, description: Total videos (YouTube, TikTok) }
                tweetCount: { type: number, description: Total tweets (X/Twitter) }
                postsCount: { type: number, description: Total posts (Bluesky) }
                pinCount: { type: number, description: Total pins (Pinterest) }
                totalViews: { type: number, description: Total channel views (YouTube) }
                likesCount: { type: number, description: Total likes received (TikTok) }
                monthlyViews: { type: number, description: Monthly profile views (Pinterest) }
                listedCount: { type: number, description: Lists the user appears on (X/Twitter) }
                boardCount: { type: number, description: Total boards (Pinterest) }
    ApiKey:
      type: object
      properties:
        id: { type: string }
        name: { type: string }
        keyPreview: { type: string }
        expiresAt: { type: string, format: date-time }
        createdAt: { type: string, format: date-time }
        key:
          type: string
          description: Returned only once, on creation
        scope:
          type: string
          enum: [full, profiles]
          description: "'full' grants access to all profiles, 'profiles' restricts to specific profiles"
          default: full
        profileIds:
          type: array
          items:
            type: object
            properties:
              _id: { type: string }
              name: { type: string }
              color: { type: string }
          description: Profiles this key can access (populated with name and color). Only present when scope is 'profiles'.
        permission:
          type: string
          enum: [read-write, read]
          description: "'read-write' allows all operations, 'read' restricts to GET requests only"
          default: read-write
    UsageStats:
      type: object
      properties:
        planName: { type: string }
        billingPeriod: { type: string, enum: [monthly, yearly] }
        signupDate: { type: string, format: date-time }
        billingAnchorDay: { type: integer, description: "Day of month (1-31) when the billing cycle resets" }
        limits:
          type: object
          properties:
            uploads: { type: integer }
            profiles: { type: integer }
        usage:
          type: object
          properties:
            uploads: { type: integer }
            profiles: { type: integer }
            lastReset: { type: string, format: date-time }
    PostAnalytics:
      type: object
      properties:
        impressions: { type: integer, example: 0 }
        reach: { type: integer, example: 0 }
        likes: { type: integer, example: 0 }
        comments: { type: integer, example: 0 }
        shares: { type: integer, example: 0 }
        saves: { type: integer, example: 0, description: 'Number of saves/bookmarks (Instagram, Pinterest)' }
        clicks: { type: integer, example: 0 }
        views: { type: integer, example: 0 }
        engagementRate: { type: number, example: 0 }
        lastUpdated: { type: string, format: date-time }
    PlatformAnalytics:
      type: object
      properties:
        platform: { type: string }
        status: { type: string, enum: [published, failed] }
        platformPostId: { type: string, nullable: true, description: 'The native post ID on the platform (e.g. Instagram media ID, tweet ID)' }
        accountId: { type: string }
        accountUsername: { type: string, nullable: true }
        analytics:
          nullable: true
          $ref: '#/components/schemas/PostAnalytics'
        syncStatus: { type: string, enum: [synced, pending, unavailable], description: 'Sync state of analytics for this platform' }
        platformPostUrl: { type: string, format: uri, nullable: true }
        errorMessage: { type: string, nullable: true, description: 'Error details when status is failed' }
    AnalyticsOverview:
      type: object
      properties:
        totalPosts: { type: integer }
        publishedPosts: { type: integer }
        scheduledPosts: { type: integer }
        lastSync: { type: string, format: date-time, nullable: true }
        dataStaleness:
          type: object
          properties:
            staleAccountCount: { type: integer, description: 'Number of accounts with stale analytics data' }
            syncTriggered: { type: boolean, description: 'Whether a background sync was triggered for stale accounts' }
    AnalyticsSinglePostResponse:
      type: object
      properties:
        postId: { type: string }
        latePostId: { type: string, nullable: true, description: 'Original Zernio post ID if scheduled via Zernio' }
        status: { type: string, enum: [published, failed, partial], description: 'Overall post status. "partial" when some platforms published and others failed.' }
        content: { type: string }
        scheduledFor: { type: string, format: date-time }
        publishedAt: { type: string, format: date-time, nullable: true }
        analytics:
          $ref: '#/components/schemas/PostAnalytics'
        platformAnalytics:
          type: array
          items:
            $ref: '#/components/schemas/PlatformAnalytics'
        platform: { type: string }
        platformPostUrl: { type: string, format: uri, nullable: true }
        isExternal: { type: boolean }
        syncStatus: { type: string, enum: [synced, pending, partial, unavailable], description: 'Overall sync state across all platforms' }
        message: { type: string, nullable: true, description: 'Human-readable status message for pending, partial, or failed states' }
        thumbnailUrl: { type: string, format: uri, nullable: true }
        mediaType: { type: string, enum: [image, video, carousel, text], nullable: true }
        mediaItems:
          type: array
          description: All media items for this post. Carousel posts contain one entry per slide.
          items:
            type: object
            properties:
              type: { type: string, enum: [image, video] }
              url: { type: string, format: uri, description: Direct URL to the media }
              thumbnail: { type: string, format: uri, description: Thumbnail URL (same as url for images) }
    AnalyticsListResponse:
      type: object
      properties:
        overview:
          $ref: '#/components/schemas/AnalyticsOverview'
        posts:
          type: array
          items:
            type: object
            properties:
              _id: { type: string }
              latePostId: { type: string, nullable: true, description: 'Original Zernio post ID if scheduled via Zernio' }
              content: { type: string }
              scheduledFor: { type: string, format: date-time }
              publishedAt: { type: string, format: date-time }
              status: { type: string }
              analytics:
                $ref: '#/components/schemas/PostAnalytics'
              platforms:
                type: array
                items:
                  $ref: '#/components/schemas/PlatformAnalytics'
              platform: { type: string }
              platformPostUrl: { type: string, format: uri }
              isExternal: { type: boolean }
              profileId: { type: string, nullable: true }
              thumbnailUrl: { type: string, format: uri }
              mediaType: { type: string, enum: [image, video, gif, document, carousel, text] }
              mediaItems:
                type: array
                description: All media items for this post. Carousel posts contain one entry per slide.
                items:
                  type: object
                  properties:
                    type: { type: string, enum: [image, video] }
                    url: { type: string, format: uri, description: Direct URL to the media }
                    thumbnail: { type: string, format: uri, description: Thumbnail URL (same as url for images) }
        pagination:
          $ref: '#/components/schemas/Pagination'
        accounts:
          type: array
          description: Connected social accounts (followerCount and followersLastUpdated only included if user has analytics add-on)
          items:
            $ref: '#/components/schemas/SocialAccount'
        hasAnalyticsAccess:
          type: boolean
          description: Whether user has analytics add-on access
    # LinkedIn Aggregate Analytics Responses
    LinkedInAggregateAnalyticsTotalResponse:
      type: object
      description: Response for TOTAL aggregation (lifetime totals)
      properties:
        accountId: { type: string }
        platform: { type: string, example: linkedin }
        accountType: { type: string, example: personal }
        username: { type: string }
        aggregation: { type: string, enum: [TOTAL] }
        dateRange:
          type: object
          nullable: true
          properties:
            startDate: { type: string, format: date }
            endDate: { type: string, format: date }
        analytics:
          type: object
          properties:
            impressions: { type: integer, description: Total impressions across all posts }
            reach: { type: integer, description: Unique members reached across all posts }
            reactions: { type: integer, description: Total reactions across all posts }
            comments: { type: integer, description: Total comments across all posts }
            shares: { type: integer, description: Total reshares across all posts }
            saves: { type: integer, description: Total times posts were saved (personal accounts only) }
            sends: { type: integer, description: Total times posts were sent via LinkedIn messaging (personal accounts only) }
            engagementRate: { type: number, description: Overall engagement rate as percentage }
        note: { type: string }
        lastUpdated: { type: string, format: date-time }
    LinkedInAggregateAnalyticsDailyResponse:
      type: object
      description: Response for DAILY aggregation (time series breakdown)
      properties:
        accountId: { type: string }
        platform: { type: string, example: linkedin }
        accountType: { type: string, example: personal }
        username: { type: string }
        aggregation: { type: string, enum: [DAILY] }
        dateRange:
          type: object
          nullable: true
          properties:
            startDate: { type: string, format: date }
            endDate: { type: string, format: date }
        analytics:
          type: object
          description: Daily breakdown of each metric as date/count pairs. Reach not available with DAILY aggregation.
          properties:
            impressions:
              type: array
              items:
                type: object
                properties:
                  date: { type: string, format: date }
                  count: { type: integer }
            reactions:
              type: array
              items:
                type: object
                properties:
                  date: { type: string, format: date }
                  count: { type: integer }
            comments:
              type: array
              items:
                type: object
                properties:
                  date: { type: string, format: date }
                  count: { type: integer }
            shares:
              type: array
              items:
                type: object
                properties:
                  date: { type: string, format: date }
                  count: { type: integer }
            saves:
              type: array
              description: Daily saves (personal accounts only)
              items:
                type: object
                properties:
                  date: { type: string, format: date }
                  count: { type: integer }
            sends:
              type: array
              description: Daily sends via LinkedIn messaging (personal accounts only)
              items:
                type: object
                properties:
                  date: { type: string, format: date }
                  count: { type: integer }
        skippedMetrics:
          type: array
          description: Metrics that were skipped due to API limitations
          items: { type: string }
        note: { type: string }
        lastUpdated: { type: string, format: date-time }
    # ============================================
    # Response Schemas
    # ============================================
    # Posts Responses
    PostsListResponse:
      type: object
      properties:
        posts:
          type: array
          items:
            $ref: '#/components/schemas/Post'
        pagination:
          $ref: '#/components/schemas/Pagination'
    PostGetResponse:
      type: object
      properties:
        post:
          $ref: '#/components/schemas/Post'
    PostCreateResponse:
      type: object
      properties:
        message:
          type: string
        post:
          $ref: '#/components/schemas/Post'
    PostUpdateResponse:
      type: object
      properties:
        message:
          type: string
        post:
          $ref: '#/components/schemas/Post'
    PostDeleteResponse:
      type: object
      properties:
        message:
          type: string
    PostRetryResponse:
      type: object
      properties:
        message:
          type: string
        post:
          $ref: '#/components/schemas/Post'
    # Profiles Responses
    ProfilesListResponse:
      type: object
      properties:
        profiles:
          type: array
          items:
            $ref: '#/components/schemas/Profile'
    ProfileGetResponse:
      type: object
      properties:
        profile:
          $ref: '#/components/schemas/Profile'
    ProfileCreateResponse:
      type: object
      properties:
        message:
          type: string
        profile:
          $ref: '#/components/schemas/Profile'
    ProfileUpdateResponse:
      type: object
      properties:
        message:
          type: string
        profile:
          $ref: '#/components/schemas/Profile'
    ProfileDeleteResponse:
      type: object
      properties:
        message:
          type: string
    # Accounts Responses
    AccountsListResponse:
      type: object
      properties:
        accounts:
          type: array
          items:
            $ref: '#/components/schemas/SocialAccount'
        hasAnalyticsAccess:
          type: boolean
          description: Whether user has analytics add-on access
    AccountGetResponse:
      type: object
      properties:
        account:
          $ref: '#/components/schemas/SocialAccount'
    FollowerStatsResponse:
      type: object
      properties:
        accounts:
          type: array
          items:
            $ref: '#/components/schemas/AccountWithFollowerStats'
        dateRange:
          type: object
          properties:
            from:
              type: string
              format: date-time
            to:
              type: string
              format: date-time
        aggregation:
          type: string
          enum: [daily, weekly, monthly]
    # Media Responses
    UploadedFile:
      type: object
      properties:
        type:
          type: string
          enum: [image, video, document]
        url:
          type: string
          format: uri
        filename:
          type: string
        size:
          type: integer
        mimeType:
          type: string
    MediaUploadResponse:
      type: object
      properties:
        files:
          type: array
          items:
            $ref: '#/components/schemas/UploadedFile'
    UploadTokenResponse:
      type: object
      properties:
        token:
          type: string
        uploadUrl:
          type: string
          format: uri
        expiresAt:
          type: string
          format: date-time
        status:
          type: string
          enum: [pending, completed, expired]
    UploadTokenStatusResponse:
      type: object
      properties:
        token:
          type: string
        status:
          type: string
          enum: [pending, completed, expired]
        files:
          type: array
          items:
            $ref: '#/components/schemas/UploadedFile'
        createdAt:
          type: string
          format: date-time
        expiresAt:
          type: string
          format: date-time
        completedAt:
          type: string
          format: date-time
    # Queue Responses
    QueueSlotsResponse:
      type: object
      properties:
        exists:
          type: boolean
        schedule:
          $ref: '#/components/schemas/QueueSchedule'
        nextSlots:
          type: array
          items:
            type: string
            format: date-time
    QueueUpdateResponse:
      type: object
      properties:
        success:
          type: boolean
        schedule:
          $ref: '#/components/schemas/QueueSchedule'
        nextSlots:
          type: array
          items:
            type: string
            format: date-time
        reshuffledCount:
          type: integer
    QueueDeleteResponse:
      type: object
      properties:
        success:
          type: boolean
        deleted:
          type: boolean
    QueuePreviewResponse:
      type: object
      properties:
        profileId:
          type: string
        count:
          type: integer
        slots:
          type: array
          items:
            type: string
            format: date-time
    QueueNextSlotResponse:
      type: object
      properties:
        profileId:
          type: string
        nextSlot:
          type: string
          format: date-time
        timezone:
          type: string
    # Users Responses
    User:
      type: object
      properties:
        _id:
          type: string
        email:
          type: string
        name:
          type: string
        role:
          type: string
        createdAt:
          type: string
          format: date-time
    UsersListResponse:
      type: object
      properties:
        users:
          type: array
          items:
            $ref: '#/components/schemas/User'
    UserGetResponse:
      type: object
      properties:
        user:
          $ref: '#/components/schemas/User'
    AdMetrics:
      type: object
      properties:
        spend: { type: number }
        impressions: { type: integer }
        reach: { type: integer }
        clicks: { type: integer }
        ctr: { type: number, description: Click-through rate (%) }
        cpc: { type: number, description: Cost per click }
        cpm: { type: number, description: Cost per 1000 impressions }
        engagement: { type: integer }
        conversions:
          type: integer
          description: "Count of conversion events matching the campaign's promoted_object.custom_event_type (PURCHASE, LEAD, etc.) over the requested date range. 0 for non-conversion campaigns or when no events have fired. Meta-only at time of writing; other platforms return 0."
        costPerConversion:
          type: number
          description: "Derived spend / conversions in the same currency as spend. 0 when conversions is 0."
        actions:
          type: object
          additionalProperties:
            type: integer
          description: "Raw per-action-type counts from Meta's Insights actions[] array, summed over the date range. Keys are Meta action_type strings (e.g. link_click, offsite_conversion.fb_pixel_purchase, onsite_conversion.lead_grouped). Use this to extract any conversion event (purchases, leads, add_to_cart, etc.) without relying on the derived conversions field. Empty object when no actions are reported."
          example:
            link_click: 160
            post_engagement: 300
            offsite_conversion.fb_pixel_purchase: 42
        actionValues:
          type: object
          additionalProperties:
            type: number
          description: "Monetary mirror of `actions`, from Meta's Insights `action_values[]` array. Same keying — values are the revenue attributed to each action_type, in ad-account native currency (same unit as `spend`; see the campaign node's `currency` field). Use this to compute revenue-per-event (e.g. avg purchase value). Meta-only; other platforms return {}."
          example:
            offsite_conversion.fb_pixel_purchase: 2456.78
            offsite_conversion.fb_pixel_add_to_cart: 980.50
        purchaseValue:
          type: number
          description: "Convenience sum of purchase-type action values — picked from `actionValues` via the same priority list as `conversions` so both fields describe the same events. In ad-account native currency. 0 when the campaign has no purchase event configured. Meta-only."
        roas:
          type: number
          description: "Return on ad spend — derived as `purchaseValue / spend`. 0 when `spend` is 0. Equivalent to Meta's `purchase_roas` under default attribution. At ad-set and campaign levels this is recomputed from summed purchaseValue + spend (NOT averaged across children) so it's mathematically correct at every rollup level."
        lastSyncedAt: { type: string, format: date-time, description: "Present on individual ads only, not on campaign aggregations" }
    AdStatus:
      type: string
      enum: [active, paused, pending_review, rejected, completed, cancelled, error]
    BusinessCenter:
      type: object
      description: |
        TikTok Business Center entity. Returned by `GET /v1/ads/business-centers`. BCs are
        TikTok's agency container — one BC owns N advertisers (ad accounts). Most solo
        advertisers don't have one; the agency token uses BCs to roll up multi-client access.
      properties:
        bcId:
          type: string
          description: Business Center ID
          example: "7123456789012345678"
        name:
          type: string
          description: Display name set by the BC owner
          example: "Acme Agency"
        advertiserCount:
          type: integer
          description: Number of advertisers (ad accounts) reachable under this BC for the calling token
          example: 23
    BidStrategy:
      type: string
      enum: [LOWEST_COST_WITHOUT_CAP, LOWEST_COST_WITH_BID_CAP, COST_CAP, LOWEST_COST_WITH_MIN_ROAS]
      description: |
        Meta bid strategy. Same enum applies at campaign and ad-set level; ad-set value (when set)
        overrides campaign-level. Cross-field rules:
          - `LOWEST_COST_WITHOUT_CAP` (default): auto-bid, forbids `bidAmount` and `roasAverageFloor`.
          - `LOWEST_COST_WITH_BID_CAP` / `COST_CAP`: require `bidAmount` (whole currency units).
          - `LOWEST_COST_WITH_MIN_ROAS`: requires `roasAverageFloor` (decimal multiplier, 2.0 = 2.0x).
        Source: facebook-business-sdk-codegen api_specs/specs/enum_types.json (`AdSet_bid_strategy`,
        `Campaign_bid_strategy`).
    AdBudget:
      type: object
      description: Budget amount in the ad account's native currency (see the campaign's `currency` field for the code).
      required: [amount, type]
      properties:
        amount: { type: number }
        type: { type: string, enum: [daily, lifetime] }
    Ad:
      type: object
      properties:
        _id: { type: string }
        name: { type: string }
        platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] }
        status: { $ref: '#/components/schemas/AdStatus' }
        adType: { type: string, enum: [boost, standalone] }
        goal: { type: string, enum: [engagement, traffic, awareness, video_views, lead_generation, conversions, app_promotion], description: "Available goals vary by platform. Meta (Facebook/Instagram) and TikTok support all 7. LinkedIn supports all except app_promotion. Twitter/X supports engagement, traffic, awareness, video_views, app_promotion. Pinterest and Google Ads support only engagement, traffic, awareness, video_views." }
        isExternal: { type: boolean, description: True for ads synced from platform ad managers }
        budget:
          type: object
          nullable: true
          properties:
            amount: { type: number }
            type: { type: string, enum: [daily, lifetime] }
        metrics:
          allOf:
            - { $ref: '#/components/schemas/AdMetrics' }
          nullable: true
        platformAdId: { type: string }
        platformAdAccountId: { type: string }
        platformCampaignId: { type: string }
        platformAdSetId: { type: string }
        campaignName: { type: string }
        adSetName: { type: string }
        platformObjective:
          type: string
          nullable: true
          description: "Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC). Only present for Meta ads."
          example: OUTCOME_SALES
        optimizationGoal:
          type: string
          nullable: true
          description: "Meta ad set optimization goal (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION, LINK_CLICKS). Only present for Meta ads."
          example: OFFSITE_CONVERSIONS
        platformAdAccountName:
          type: string
          nullable: true
          description: |
            Human-readable advertiser/account name (Meta `AdAccount.name`, TikTok
            `advertiser_name`, LinkedIn / X / Pinterest equivalents). Refreshed every
            sync so platform-side renames propagate within one cycle. `null` when the
            platform doesn't return a name or the sync hasn't run yet.
          example: "Zernio - previously Late"
        platformCreatedAt:
          type: string
          format: date-time
          nullable: true
          description: |
            Platform-reported creation timestamp (Meta `created_time`, TikTok `create_time`).
            Distinct from `createdAt` which reflects when Zernio first synced the doc — for
            sort/filter by "when the ad was actually created on the platform", read this field.
            `null` for legacy ads synced before this field was added; aggregations fall back
            to `createdAt` in that case.
        bidStrategy:
          allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
          nullable: true
          description: |
            Ad-set bid strategy (overrides campaign level on Meta). Populated for Meta and
            TikTok. TikTok's native `bid_type` is normalized to the cross-platform Meta enum:
            `BID_TYPE_NO_BID` -> `LOWEST_COST_WITHOUT_CAP`, `BID_TYPE_CUSTOM` ->
            `LOWEST_COST_WITH_BID_CAP`, deep_bid_type=MIN_ROAS or roas_bid>0 ->
            `LOWEST_COST_WITH_MIN_ROAS`, `BID_TYPE_MAX_CONVERSION` -> `LOWEST_COST_WITHOUT_CAP`.
          example: LOWEST_COST_WITHOUT_CAP
        bidAmount:
          type: number
          nullable: true
          description: |
            Bid cap in WHOLE currency units of the ad account (USD: 5 = $5.00; JPY: 100 = ¥100).
            Populated when bidStrategy is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`. `null` for
            auto-bid (`LOWEST_COST_WITHOUT_CAP`).

            - Meta source: `bid_amount` on the ad set (smallest-denomination int, decoded here).
            - TikTok source: priority order `bid_price` -> `conversion_bid_price` -> `deep_cpa_bid`
              (whichever is set on the ad group). TikTok stores all three in whole currency units.

            Source: facebook-business-sdk-codegen api_specs/specs/AdSet.json (`bid_amount`).
          example: 5
        roasAverageFloor:
          type: number
          nullable: true
          description: |
            Minimum ROAS as a decimal multiplier (2.0 = 2.0x ROAS). Populated when bidStrategy
            is `LOWEST_COST_WITH_MIN_ROAS`.

            - Meta source: decoded from `bid_constraints.roas_average_floor` (Meta stores as
              fixed-point int × 10000; we return the decimal).
            - TikTok source: `roas_bid` on the ad group (already a decimal).

            Source: facebook-business-sdk-codegen api_specs/specs/AdCampaignBidConstraint.json.
          example: 2.0
        promotedObject:
          type: object
          nullable: true
          description: "Meta promoted object containing conversion event details. Structure varies by objective. Only present for Meta ads."
          properties:
            custom_event_type: { type: string, description: "Conversion event type (e.g. PURCHASE, LEAD, COMPLETE_REGISTRATION, ADD_TO_CART)", example: PURCHASE }
            pixel_id: { type: string, description: Meta pixel ID }
            page_id: { type: string, description: Facebook page ID }
            application_id: { type: string, description: Facebook app ID }
            product_set_id: { type: string, description: Product catalog set ID }
        creative:
          type: object
          nullable: true
          description: Platform-specific creative data. Fields vary by platform.
          properties:
            thumbnailUrl: { type: string, description: Primary thumbnail/image URL }
            imageUrl: { type: string, description: Alternative image URL }
            videoId: { type: string, nullable: true, description: "Meta video ID for VIDEO-type ads. Null for non-video ads. Callers that need an embeddable MP4 can call GET /{videoId}?fields=source with the page access token." }
            videoUrl: { type: string, nullable: true, description: "Public Facebook watch URL for VIDEO-type ads (https://www.facebook.com/watch/?v={videoId}). Null for non-video ads." }
            objectType: { type: string, description: "Meta creative object_type (e.g. SHARE, VIDEO, PRIVACY_CHECK_FAIL, POST_DELETED). Use this to render state-aware previews — when Meta moderation strips image/video fields, only thumbnailUrl at 64x64 is available." }
            mediaUrls:
              type: array
              items: { type: string }
              description: All media URLs for this ad (carousel images, multiple assets). Populated for Meta (carousel child_attachments), Google Ads (responsive display marketing_images), and LinkedIn (multi-image posts).
            body: { type: string, description: Ad copy/text }
            googleHeadline: { type: string, description: Google Ads headline }
            googleDescription: { type: string, description: Google Ads description }
            linkUrl: { type: string, description: Destination URL }
            pinterestImageUrl: { type: string }
            pinterestTitle: { type: string }
            pinterestDescription: { type: string }
        targeting: { type: object }
        schedule:
          type: object
          nullable: true
          properties:
            startDate: { type: string, format: date-time }
            endDate: { type: string, format: date-time }
        rejectionReason: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    AdTreeAdSet:
      type: object
      description: Ad set (or ad group/line item depending on platform) with rolled-up metrics and child ads
      properties:
        platformAdSetId: { type: string }
        adSetName: { type: string }
        status: { allOf: [{ $ref: '#/components/schemas/AdStatus' }], description: Derived from child ad statuses }
        adCount: { type: integer }
        budget:
          type: object
          nullable: true
          description: Effective budget at this level (back-compat). For CBO campaigns this mirrors the parent campaign's budget; for ABO this is the ad-set-specific budget. Use `adSetBudget` / parent `campaignBudget` + `budgetLevel` to disambiguate.
          properties:
            amount: { type: number }
            type: { type: string, enum: [daily, lifetime] }
        adSetBudget:
          type: object
          nullable: true
          description: Ad-set-level budget (ABO). Null for CBO campaigns where the budget is set on the campaign.
          properties:
            amount: { type: number }
            type: { type: string, enum: [daily, lifetime] }
        metrics: { $ref: '#/components/schemas/AdMetrics' }
        optimizationGoal: { type: string, nullable: true, description: "Meta ad set optimization goal (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION)" }
        bidStrategy:
          allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
          nullable: true
          description: "Bid strategy for this ad set (overrides campaign level when set)"
        bidAmount: { type: number, nullable: true, description: "Bid cap in whole currency units. Populated when bidStrategy is LOWEST_COST_WITH_BID_CAP or COST_CAP." }
        roasAverageFloor: { type: number, nullable: true, description: "Minimum ROAS as a decimal multiplier (2.0 = 2.0x). Populated when bidStrategy is LOWEST_COST_WITH_MIN_ROAS." }
        promotedObject:
          type: object
          nullable: true
          description: "Meta promoted object for this ad set (conversion event details)"
          properties:
            custom_event_type: { type: string }
            pixel_id: { type: string }
            page_id: { type: string }
        ads:
          type: array
          items: { $ref: '#/components/schemas/Ad' }
          description: Individual ads within this ad set (capped at 100). Returns a subset of Ad fields from the aggregation (core fields like _id, name, platform, status, budget, metrics, creative, goal are included; targeting and schedule may be absent).
    AdTreeCampaign:
      type: object
      description: Campaign with nested ad sets and rolled-up metrics
      properties:
        platformCampaignId: { type: string }
        platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] }
        campaignName: { type: string }
        status: { allOf: [{ $ref: '#/components/schemas/AdStatus' }], description: "Delivery status derived from child ad statuses. Distinct from `reviewStatus`, which reflects the platform-side review state." }
        reviewStatus:
          type: string
          nullable: true
          enum: [in_review, approved, rejected, with_issues]
          description: |
            Platform-side review state of the campaign. Independent of the
            children-derived delivery `status`: a campaign can have ads
            already active (status=active) while the campaign itself is
            still being reviewed by the platform (reviewStatus=in_review).
            For Meta, derived from `effective_status` + `issues_info` on
            the Campaign, plus ad-level PENDING_REVIEW rollup.
        platformCampaignStatus:
          type: string
          nullable: true
          description: "Raw platform-level campaign status (Meta `effective_status`: ACTIVE, PAUSED, DELETED, ARCHIVED, IN_PROCESS, WITH_ISSUES). Distinct from per-ad `platformStatus`."
        campaignIssuesInfo:
          type: array
          nullable: true
          description: "Platform-reported campaign issues (Meta `issues_info[]`). Populated only when the platform has delivery issues to report; contains the specific error codes and messages."
          items:
            type: object
        adCount: { type: integer, description: Total ads across all ad sets }
        adSetCount: { type: integer }
        budget:
          type: object
          nullable: true
          description: Effective budget (back-compat). For CBO this mirrors `campaignBudget`, for ABO this mirrors the child ad-set budget. Use `budgetLevel` to disambiguate.
          properties:
            amount: { type: number }
            type: { type: string, enum: [daily, lifetime] }
        campaignBudget:
          type: object
          nullable: true
          description: Campaign-level budget (Campaign Budget Optimization / CBO). Populated only when the platform set the budget at the campaign level. For ABO campaigns this is null and the budget lives on the child ad set.
          properties:
            amount: { type: number }
            type: { type: string, enum: [daily, lifetime] }
        budgetLevel:
          type: string
          nullable: true
          enum: [campaign, adset]
          description: "Canonical CBO/ABO indicator. `campaign` = CBO (Advantage Campaign Budget, budget lives on the campaign). `adset` = ABO (budget lives on each ad set). Route budget updates to the matching Meta entity."
        isBudgetScheduleEnabled:
          type: boolean
          default: false
          description: "Meta-only. Mirrors Campaign.is_budget_schedule_enabled — true when the campaign uses budget scheduling (time-based budget changes). Independent of CBO/ABO."
        currency:
          type: string
          nullable: true
          description: "ISO 4217 currency code (e.g. USD, EUR, CLP, JPY) for all budget amounts in this campaign node. Budgets are NOT normalized to USD."
        metrics: { $ref: '#/components/schemas/AdMetrics' }
        platformAdAccountId: { type: string }
        platformAdAccountName: { type: string, nullable: true, description: "Human-readable advertiser/account name from the platform. Refreshed on every sync." }
        accountId: { type: string }
        profileId: { type: string }
        platformObjective: { type: string, nullable: true, description: "Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC)" }
        optimizationGoal:
          type: string
          nullable: true
          description: "Meta optimization goal shared across ad sets, or comma-separated values when ad sets differ (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION)"
        bidStrategy:
          allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
          nullable: true
          description: "Campaign-level bid strategy. Ad sets inherit this unless they override."
        bidAmount: { type: number, nullable: true, description: "Representative bid cap for the campaign — bubbled up from the top-spending ad set's `bid_amount` (whole currency units). Populated when the ad-set bidStrategy is LOWEST_COST_WITH_BID_CAP or COST_CAP." }
        roasAverageFloor: { type: number, nullable: true, description: "Representative ROAS floor for the campaign — bubbled up from the top-spending ad set. Decimal multiplier (2.0 = 2.0x)." }
        promotedObject:
          type: object
          nullable: true
          description: "Meta promoted object at campaign level (conversion event details)"
          properties:
            custom_event_type: { type: string }
            pixel_id: { type: string }
            page_id: { type: string }
        adSets:
          type: array
          items: { $ref: '#/components/schemas/AdTreeAdSet' }
    AdCampaign:
      type: object
      properties:
        platformCampaignId: { type: string }
        platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] }
        campaignName: { type: string }
        status: { allOf: [{ $ref: '#/components/schemas/AdStatus' }], description: "Delivery status derived from child ad statuses. Distinct from `reviewStatus`." }
        reviewStatus:
          type: string
          nullable: true
          enum: [in_review, approved, rejected, with_issues]
          description: "Platform-side review state of the campaign. See AdTreeCampaign.reviewStatus for the full description."
        platformCampaignStatus:
          type: string
          nullable: true
          description: "Raw platform-level campaign status (Meta `effective_status`)."
        campaignIssuesInfo:
          type: array
          nullable: true
          description: "Platform-reported campaign issues (Meta `issues_info[]`)."
          items:
            type: object
        adCount: { type: integer }
        budget:
          type: object
          nullable: true
          description: Effective budget (back-compat). Use `budgetLevel` to disambiguate CBO vs ABO.
          properties:
            amount: { type: number }
            type: { type: string, enum: [daily, lifetime] }
        campaignBudget:
          type: object
          nullable: true
          description: Campaign-level budget (CBO). Null for ABO campaigns.
          properties:
            amount: { type: number }
            type: { type: string, enum: [daily, lifetime] }
        budgetLevel:
          type: string
          nullable: true
          enum: [campaign, adset]
          description: "Canonical CBO/ABO indicator. See AdTreeCampaign.budgetLevel."
        isBudgetScheduleEnabled:
          type: boolean
          default: false
          description: "Meta-only. Mirrors Campaign.is_budget_schedule_enabled."
        currency:
          type: string
          nullable: true
          description: "ISO 4217 currency code for all budget amounts. Budgets are NOT normalized to USD."
        metrics: { $ref: '#/components/schemas/AdMetrics' }
        platformAdAccountId: { type: string }
        platformAdAccountName: { type: string, nullable: true, description: "Human-readable advertiser/account name from the platform. Refreshed on every sync." }
        accountId: { type: string }
        profileId: { type: string }
        platformObjective: { type: string, nullable: true, description: "Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC)" }
        optimizationGoal:
          type: string
          nullable: true
          description: "Meta optimization goal shared across ad sets, or comma-separated values when ad sets differ (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION)"
        bidStrategy:
          allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
          nullable: true
          description: "Campaign-level bid strategy. Ad sets inherit this unless they override."
        bidAmount: { type: number, nullable: true, description: "Representative bid cap from the top-spending ad set (whole currency units). Populated when bidStrategy is LOWEST_COST_WITH_BID_CAP or COST_CAP." }
        roasAverageFloor: { type: number, nullable: true, description: "Representative ROAS floor from the top-spending ad set. Decimal multiplier (2.0 = 2.0x)." }
        promotedObject:
          type: object
          nullable: true
          description: "Meta promoted object at campaign level (conversion event details)"
          properties:
            custom_event_type: { type: string }
            pixel_id: { type: string }
            page_id: { type: string }
        earliestAd: { type: string, format: date-time }
        latestAd: { type: string, format: date-time }
    ConversionEvent:
      type: object
      description: |
        A single conversion event to relay to the ad platform. All PII fields
        (email, phone, names) are hashed with SHA-256 server-side using each
        platform's normalization rules before they leave Zernio. Callers send
        plaintext.
      required: [eventName, eventTime, eventId, user]
      properties:
        eventName:
          type: string
          description: |
            Standard event name (Purchase, Lead, CompleteRegistration, AddToCart,
            InitiateCheckout, AddPaymentInfo, Subscribe, StartTrial, ViewContent,
            Search, Contact, SubmitApplication, Schedule) or a custom string
            (only supported on platforms that accept custom events).
          example: Purchase
        eventTime:
          type: integer
          description: When the conversion happened, in unix seconds.
          example: 1744732800
        eventId:
          type: string
          description: |
            Unique dedup key. The same eventId must be used on pixel + CAPI
            to prevent double-counting. Mapped to event_id on Meta and
            transactionId on Google.
          example: order_abc_123
        value:
          type: number
          description: Conversion value in the specified currency.
          example: 99.5
        currency:
          type: string
          description: ISO 4217 currency code.
          example: USD
        user:
          type: object
          description: User identity fields. More signals mean higher match rates.
          properties:
            email: { type: string, description: Plaintext email. Hashed server-side. }
            phone: { type: string, description: "Phone number, ideally E.164. Hashed server-side." }
            firstName: { type: string, description: Plaintext first name. Hashed server-side. }
            lastName: { type: string, description: Plaintext last name. Hashed server-side. }
            externalId: { type: string, description: "Stable customer identifier (e.g. CRM user ID). Hashed server-side." }
            ipAddress: { type: string, description: Client IP address. Sent plaintext. }
            userAgent: { type: string, description: Client user-agent string. Sent plaintext. }
            country: { type: string, description: "ISO 3166-1 alpha-2 country code, e.g. 'us'." }
            clickIds:
              type: object
              description: Platform click identifiers captured from the originating ad click.
              properties:
                fbc: { type: string, description: Meta click ID (from fbclid URL param). }
                fbp: { type: string, description: Meta browser ID (_fbp cookie). }
                gclid: { type: string, description: Google click ID (from gclid URL param). }
                gbraid: { type: string, description: Google iOS 14.5+ app attribution ID. }
                wbraid: { type: string, description: Google iOS 14.5+ web-to-app attribution ID. }
        items:
          type: array
          description: Item-level detail for ecommerce events.
          items:
            type: object
            properties:
              id: { type: string }
              name: { type: string }
              price: { type: number }
              quantity: { type: integer }
              category: { type: string }
        sourceUrl:
          type: string
          format: uri
          description: URL where the conversion originated (used by Meta).
        actionSource:
          type: string
          enum: [web, app, offline, crm, phone_call, system_generated]
          description: Where the conversion happened. Used by Meta; Google ignores.
        platformData:
          type: object
          additionalProperties: true
          description: Escape hatch for platform-specific fields we haven't normalized. Forwarded as-is.
webhooks:
  post.scheduled:
    post:
      operationId: onPostScheduled
      summary: Post scheduled event
      description: Fired when a post is created and scheduled for publishing.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadPost'
      responses:
        '200':
          description: Webhook received successfully
  post.published:
    post:
      operationId: onPostPublished
      summary: Post published event
      description: Fired when a post is successfully published.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadPost'
      responses:
        '200':
          description: Webhook received successfully
  post.failed:
    post:
      operationId: onPostFailed
      summary: Post failed event
      description: Fired when a post fails to publish on all target platforms.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadPost'
      responses:
        '200':
          description: Webhook received successfully
  post.partial:
    post:
      operationId: onPostPartial
      summary: Post partial event
      description: Fired when a post publishes on some platforms and fails on others.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadPost'
      responses:
        '200':
          description: Webhook received successfully
  post.cancelled:
    post:
      operationId: onPostCancelled
      summary: Post cancelled event
      description: Fired when a post publishing job is cancelled.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadPost'
      responses:
        '200':
          description: Webhook received successfully
  post.recycled:
    post:
      operationId: onPostRecycled
      summary: Post recycled event
      description: Fired when a post is recycled (cloned and re-scheduled for publishing).
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadPost'
      responses:
        '200':
          description: Webhook received successfully
  account.connected:
    post:
      operationId: onAccountConnected
      summary: Account connected event
      description: Fired when a social account is successfully connected.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadAccountConnected'
      responses:
        '200':
          description: Webhook received successfully
  account.disconnected:
    post:
      operationId: onAccountDisconnected
      summary: Account disconnected event
      description: Fired when a connected social account becomes disconnected.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadAccountDisconnected'
      responses:
        '200':
          description: Webhook received successfully
  account.ads.initial_sync_completed:
    post:
      operationId: onAccountAdsInitialSyncCompleted
      summary: Ads initial sync completed event
      description: |
        Fired once per ads-enabled account when the initial sync (ad-account
        discovery + 90-day historical ad backfill) completes. The `sync` block
        reports whether the backfill succeeded and how many ads were synced.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadAccountAdsInitialSyncCompleted'
      responses:
        '200':
          description: Webhook received successfully
  message.received:
    post:
      operationId: onMessageReceived
      summary: Message received event
      description: Fired when a new inbox message is received.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadMessage'
      responses:
        '200':
          description: Webhook received successfully
  message.sent:
    post:
      operationId: onMessageSent
      summary: Message sent event
      description: Fired when a message is sent via the API.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadMessageSent'
      responses:
        '200':
          description: Webhook received successfully
  message.edited:
    post:
      operationId: onMessageEdited
      summary: Message edited event
      description: |
        Fired when a sender edits a previously-sent message. Supported on
        Instagram, Facebook Messenger, and Telegram. The payload includes the
        full editHistory so consumers can show prior versions.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadMessageEdited'
      responses:
        '200':
          description: Webhook received successfully
  message.deleted:
    post:
      operationId: onMessageDeleted
      summary: Message deleted event
      description: |
        Fired when a sender deletes (unsends) a message. Supported on Instagram
        (incoming unsend) and WhatsApp (when the business deletes an outgoing
        message via the Cloud API). The payload retains the pre-delete text
        and attachments so API consumers can access the original content for
        moderation or compliance — the Zernio dashboard UI hides it.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadMessageDeleted'
      responses:
        '200':
          description: Webhook received successfully
  message.delivered:
    post:
      operationId: onMessageDelivered
      summary: Message delivered event
      description: |
        Fired when an outgoing message is delivered to the recipient.
        Supported on WhatsApp and Facebook Messenger.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadMessageDeliveryStatus'
      responses:
        '200':
          description: Webhook received successfully
  message.read:
    post:
      operationId: onMessageRead
      summary: Message read event
      description: |
        Fired when an outgoing message is read by the recipient. Supported on
        WhatsApp, Facebook Messenger, and Instagram.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadMessageDeliveryStatus'
      responses:
        '200':
          description: Webhook received successfully
  message.failed:
    post:
      operationId: onMessageFailed
      summary: Message delivery failed event
      description: |
        Fired when an outgoing message fails to deliver. Currently only emitted
        for WhatsApp (other platforms don't expose per-message failure via
        webhook). The payload error object contains code, title, and
        message from the platform.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadMessageDeliveryStatus'
      responses:
        '200':
          description: Webhook received successfully
  comment.received:
    post:
      operationId: onCommentReceived
      summary: Comment received event
      description: Fired when a new comment is received on a tracked post.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadComment'
      responses:
        '200':
          description: Webhook received successfully
  review.new:
    post:
      operationId: onReviewNew
      summary: Review new event
      description: |
        Fired when a new review is posted on a connected account. Currently supported
        for Google Business Profile (real-time via Pub/Sub). Requires the Inbox add-on.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadReviewNew'
      responses:
        '200':
          description: Webhook received successfully
  review.updated:
    post:
      operationId: onReviewUpdated
      summary: Review updated event
      description: |
        Fired when a review changes: the reviewer edits their text or rating, or a
        reply is added (via the API or directly through the Google Business dashboard).
        Payload shape matches review.new. Requires the Inbox add-on.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadReviewUpdated'
      responses:
        '200':
          description: Webhook received successfully
  webhook.test:
    post:
      operationId: onWebhookTest
      summary: Webhook test event
      description: Fired when sending a test webhook to verify the endpoint configuration.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadTest'
      responses:
        '200':
          description: Webhook received successfully
security:
  - bearerAuth: []
paths:
  # NOTE: Tools download endpoints (/v1/tools/{platform}/download, /transcript, /hashtag-checker) removed from docs but still functional for existing customers

  # ============================================
  # Validate
  # ============================================
  /v1/tools/validate/post-length:
    post:
      operationId: validatePostLength
      tags: [Validate]
      summary: Validate character count
      description: |
        Check weighted character count per platform and whether the text is within each platform's limit.

        Twitter/X uses weighted counting (URLs = 23 chars via t.co, emojis = 2 chars). All other platforms use plain character length.

        Returns counts and limits for all 15 supported platform variants.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [text]
              properties:
                text:
                  type: string
                  description: The post text to check
                  example: "Check out https://zernio.com for scheduling posts!"
      responses:
        "200":
          description: Character counts per platform
          content:
            application/json:
              schema:
                type: object
                properties:
                  text: { type: string }
                  platforms:
                    type: object
                    additionalProperties:
                      type: object
                      properties:
                        count: { type: integer, description: "Character count for this platform" }
                        limit: { type: integer, description: "Maximum allowed characters" }
                        valid: { type: boolean, description: "Whether the text is within the limit" }
                    example:
                      twitter: { count: 51, limit: 280, valid: true }
                      twitterPremium: { count: 51, limit: 25000, valid: true }
                      instagram: { count: 51, limit: 2200, valid: true }
                      bluesky: { count: 51, limit: 300, valid: true }
                      snapchat: { count: 51, limit: 160, valid: true }

  /v1/tools/validate/post:
    post:
      operationId: validatePost
      tags: [Validate]
      summary: Validate post content
      description: |
        Dry-run the full post validation pipeline without publishing. Catches issues like missing media for Instagram/TikTok/YouTube, hashtag limits, invalid thread formats, Facebook Reel requirements, and character limit violations.

        Accepts the same body as POST /v1/posts. Does NOT validate accounts, process media, or track usage. This is content-only validation.

        Returns errors for failures and warnings for near-limit content (>90% of character limit).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platforms]
              properties:
                content:
                  type: string
                  description: Post text content
                  example: "Check out this video!"
                platforms:
                  type: array
                  description: Target platforms (same format as POST /v1/posts)
                  items:
                    type: object
                    required: [platform]
                    properties:
                      platform:
                        type: string
                        enum: [twitter, instagram, tiktok, youtube, facebook, linkedin, bluesky, threads, reddit, pinterest, telegram, snapchat, googlebusiness, discord]
                      customContent: { type: string }
                      platformSpecificData: { type: object }
                      customMedia:
                        type: array
                        items:
                          type: object
                          properties:
                            url: { type: string }
                            type: { type: string, enum: [image, video] }
                  example:
                    - platform: youtube
                    - platform: twitter
                mediaItems:
                  type: array
                  description: Root media items shared across platforms
                  items:
                    type: object
                    properties:
                      url: { type: string, format: uri }
                      type: { type: string, enum: [image, video] }
      responses:
        "200":
          description: Validation result
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    description: Valid post
                    properties:
                      valid: { type: boolean }
                      message: { type: string, example: "No validation issues found." }
                      warnings:
                        type: array
                        items:
                          type: object
                          properties:
                            platform: { type: string }
                            warning: { type: string }
                  - type: object
                    description: Invalid post
                    properties:
                      valid: { type: boolean }
                      errors:
                        type: array
                        items:
                          type: object
                          properties:
                            platform: { type: string }
                            error: { type: string }
                      warnings:
                        type: array
                        items:
                          type: object
                          properties:
                            platform: { type: string }
                            warning: { type: string }

  /v1/tools/validate/media:
    post:
      operationId: validateMedia
      tags: [Validate]
      summary: Validate media URL
      description: |
        Check if a media URL is accessible and return metadata (content type, file size) plus per-platform size limit comparisons.

        Performs a HEAD request (with GET fallback) to detect content type and size. Rejects private/localhost URLs for SSRF protection.

        Platform limits are sourced from each platform's actual upload constraints.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [url]
              properties:
                url:
                  type: string
                  format: uri
                  description: Public media URL to validate
                  example: "https://example.com/image.jpg"
      responses:
        "200":
          description: Media validation result
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid: { type: boolean }
                  url: { type: string, format: uri }
                  error: { type: string, description: "Error message if valid is false" }
                  contentType: { type: string, example: "image/jpeg" }
                  size: { type: integer, nullable: true, description: "File size in bytes" }
                  sizeFormatted: { type: string, example: "245 KB" }
                  type: { type: string, enum: [image, video, unknown] }
                  platformLimits:
                    type: object
                    description: Per-platform size limit comparison (only present when size and type are known)
                    additionalProperties:
                      type: object
                      properties:
                        limit: { type: integer, description: "Platform size limit in bytes" }
                        limitFormatted: { type: string }
                        withinLimit: { type: boolean }
                    example:
                      instagram: { limit: 8388608, limitFormatted: "8.0 MB", withinLimit: true }
                      twitter: { limit: 5242880, limitFormatted: "5.0 MB", withinLimit: true }
                      bluesky: { limit: 1000000, limitFormatted: "977 KB", withinLimit: true }

  /v1/tools/validate/subreddit:
    get:
      operationId: validateSubreddit
      tags: [Validate]
      summary: Check subreddit existence
      description: |
        Check if a subreddit exists and return basic info (title, subscriber count, NSFW status, post types allowed).

        When accountId is provided, uses authenticated Reddit OAuth API with automatic token refresh (recommended). Falls back to Reddit's public JSON API, which may be unreliable from server IPs. Returns exists: false for private, banned, or nonexistent subreddits.
      security:
        - bearerAuth: []
      parameters:
        - name: name
          in: query
          required: true
          description: Subreddit name (with or without "r/" prefix)
          schema:
            type: string
            example: "programming"
        - name: accountId
          in: query
          description: Reddit social account ID for authenticated lookup (recommended for reliable results)
          schema:
            type: string
      responses:
        "200":
          description: Subreddit lookup result
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    description: Subreddit exists
                    properties:
                      exists: { type: boolean }
                      subreddit:
                        type: object
                        properties:
                          name: { type: string, example: "programming" }
                          title: { type: string, example: "programming" }
                          description: { type: string, example: "Computer Programming" }
                          subscribers: { type: integer, example: 6844284 }
                          isNSFW: { type: boolean }
                          type: { type: string, enum: [public, private, restricted], example: "public" }
                          allowImages: { type: boolean }
                          allowVideos: { type: boolean }
                  - type: object
                    description: Subreddit not found
                    properties:
                      exists: { type: boolean }
                      error: { type: string }

  # ============================================
  # Analytics
  # ============================================
  /v1/analytics:
    get:
      operationId: getAnalytics
      tags: [Analytics]
      summary: Get post analytics
      description: |
        Returns analytics for posts. With postId, returns a single post. Without it, returns a paginated list with overview stats.
        Accepts both Zernio Post IDs and External Post IDs (auto-resolved). fromDate defaults to 90 days ago if omitted, max range 366 days.
        Single post lookups may return 202 (sync pending) or 424 (all platforms failed). For follower stats, use /v1/accounts/follower-stats.

        LinkedIn personal accounts: Analytics are only available for posts published through Zernio. LinkedIn's API only returns metrics for posts authored by the authenticated user. Organization/company page analytics work for all posts.
      parameters:
        - name: postId
          in: query
          schema: { type: string }
          description: Returns analytics for a single post. Accepts both Zernio Post IDs and External Post IDs. Zernio IDs are auto-resolved to External Post analytics.
        - name: platform
          in: query
          schema: { type: string }
          description: Filter by platform (default "all")
        - name: profileId
          in: query
          schema: { type: string }
          description: Filter by profile ID (default "all")
        - name: accountId
          in: query
          schema: { type: string }
          description: Filter by social account ID
        - name: source
          in: query
          schema: { type: string, enum: [all, late, external], default: all }
          description: "Filter by post source: late (posted via Zernio API), external (synced from platform), all (default)"
        - name: fromDate
          in: query
          schema: { type: string, format: date }
          description: Inclusive lower bound (YYYY-MM-DD). Defaults to 90 days ago if omitted. Max range is 366 days.
        - name: toDate
          in: query
          schema: { type: string, format: date }
          description: Inclusive upper bound (YYYY-MM-DD). Defaults to today if omitted.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
          description: Page size (default 50)
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
          description: Page number (default 1)
        - name: sortBy
          in: query
          schema: { type: string, enum: [date, engagement, impressions, reach, likes, comments, shares, saves, clicks, views], default: date }
          description: Sort by date, engagement, or a specific metric
        - name: order
          in: query
          schema: { type: string, enum: [asc, desc], default: desc }
          description: Sort order
      responses:
        '200':
          description: Analytics result
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/AnalyticsSinglePostResponse'
                  - $ref: '#/components/schemas/AnalyticsListResponse'
              examples:
                singlePost:
                  summary: Single post analytics (Zernio post with synced analytics)
                  value:
                    postId: "65f1c0a9e2b5af0012ab34cd"
                    latePostId: null
                    status: "published"
                    content: "Check out our new product launch!"
                    scheduledFor: "2024-11-01T10:00:00Z"
                    publishedAt: "2024-11-01T10:00:05Z"
                    analytics:
                      impressions: 15420
                      reach: 12350
                      likes: 342
                      comments: 28
                      shares: 45
                      saves: 0
                      clicks: 189
                      views: 0
                      engagementRate: 2.78
                      lastUpdated: "2024-11-02T08:30:00Z"
                    platformAnalytics:
                      - platform: "twitter"
                        status: "published"
                        platformPostId: "123456789"
                        accountId: "64e1f0a9e2b5af0012ab34cd"
                        accountUsername: "@acmecorp"
                        analytics:
                          impressions: 15420
                          reach: 12350
                          likes: 342
                          comments: 28
                          shares: 45
                          saves: 0
                          clicks: 189
                          views: 0
                          engagementRate: 2.78
                          lastUpdated: "2024-11-02T08:30:00Z"
                        syncStatus: "synced"
                        platformPostUrl: "https://twitter.com/acmecorp/status/123456789"
                        errorMessage: null
                    platform: "twitter"
                    platformPostUrl: "https://twitter.com/acmecorp/status/123456789"
                    isExternal: false
                    syncStatus: "synced"
                    message: null
                    thumbnailUrl: "https://storage.example.com/image.jpg"
                    mediaType: "image"
                    mediaItems:
                      - type: "image"
                        url: "https://storage.example.com/image.jpg"
                        thumbnail: "https://storage.example.com/image.jpg"
                postList:
                  summary: Paginated analytics list
                  description: |
                    Note: The list endpoint returns External Post IDs. Posts originally
                    scheduled via Zernio will have isExternal: true in this response.
                    Use platformPostUrl to correlate with your original Zernio Post IDs.
                  value:
                    overview:
                      totalPosts: 156
                      publishedPosts: 156
                      scheduledPosts: 0
                      lastSync: "2024-11-02T08:30:00Z"
                      dataStaleness:
                        staleAccountCount: 0
                        syncTriggered: false
                    posts:
                      - _id: "65f1c0a9e2b5af0012ab34cd"
                        latePostId: "65f1c0a9e2b5af0012ab34ab"
                        content: "Check out our new product launch!"
                        scheduledFor: "2024-11-01T10:00:00Z"
                        publishedAt: "2024-11-01T10:00:05Z"
                        status: "published"
                        analytics:
                          impressions: 15420
                          reach: 12350
                          likes: 342
                          comments: 28
                          shares: 45
                          saves: 0
                          clicks: 189
                          views: 0
                          engagementRate: 2.78
                          lastUpdated: "2024-11-02T08:30:00Z"
                        platforms:
                          - platform: "instagram"
                            status: "published"
                            platformPostId: "17902345678901234"
                            accountId: "64e1f0a9e2b5af0012ab34cd"
                            accountUsername: "@acmecorp"
                            analytics:
                              impressions: 15420
                              reach: 12350
                              likes: 342
                              comments: 28
                              shares: 45
                              saves: 0
                              clicks: 189
                              views: 0
                              engagementRate: 2.78
                              lastUpdated: "2024-11-02T08:30:00Z"
                            syncStatus: "synced"
                            platformPostUrl: "https://www.instagram.com/reel/ABC123xyz/"
                            errorMessage: null
                        platform: "instagram"
                        platformPostUrl: "https://www.instagram.com/reel/ABC123xyz/"
                        isExternal: true
                        profileId: "64e1f0a9e2b5af0012ab34cd"
                        thumbnailUrl: "https://storage.example.com/thumb.jpg"
                        mediaType: "carousel"
                        mediaItems:
                          - type: "image"
                            url: "https://storage.example.com/slide1.jpg"
                            thumbnail: "https://storage.example.com/slide1.jpg"
                          - type: "image"
                            url: "https://storage.example.com/slide2.jpg"
                            thumbnail: "https://storage.example.com/slide2.jpg"
                    pagination:
                      page: 1
                      limit: 50
                      total: 156
                      pages: 4
                    accounts:
                      - _id: "64e1f0..."
                        platform: "twitter"
                        username: "@acmecorp"
                        displayName: "Acme Corp"
                        isActive: true
                    hasAnalyticsAccess: true
        '202':
          description: Analytics are being synced from the platform (single post lookup only). The response body matches AnalyticsSinglePostResponse with syncStatus "pending" and a message.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalyticsSinglePostResponse'
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: Invalid query parameters }
                  details: { type: object, description: 'Detailed validation errors' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics add-on required
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: Analytics add-on required }
                  code: { type: string, example: analytics_addon_required }
        '404': { $ref: '#/components/responses/NotFound' }
        '424':
          description: Post failed to publish on all platforms. Analytics are unavailable. (single post lookup only)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/AnalyticsSinglePostResponse'
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v1/analytics/youtube/channel-insights:
    get:
      operationId: getYouTubeChannelInsights
      tags: [Analytics]
      summary: Get YouTube channel-level insights
      description: |
        Returns channel-scoped aggregate metrics from YouTube Analytics API v2. Saves you
        from looping /v1/analytics/youtube/daily-views over every video when you only need
        channel totals.

        Response shape matches /v1/analytics/instagram/account-insights so the same client
        handling works. Requires yt-analytics.readonly scope (412 with reauthorizeUrl if
        missing). Data has a 2-3 day delay (endDate is clamped accordingly). Max 89 days,
        defaults to last 30 days. Requires the Analytics add-on.

        NOT exposed: impressions (Studio thumbnail impressions) and impressionsClickThroughRate.
        YouTube Analytics API v2 does not expose these for any principal type, not channel
        owners, not Partner Program channels, not content owners with CMS access. The only way
        to get them is Studio CSV export. This is a Google-side limitation.
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: The Zernio SocialAccount ID for the YouTube account.
        - name: metrics
          in: query
          schema: { type: string }
          description: |
            Comma-separated list. Defaults to "views,estimatedMinutesWatched,subscribersGained,subscribersLost".

            Live YouTube Analytics v2 metrics:
              - views
              - estimatedMinutesWatched
              - averageViewDuration          (ratio - weighted mean computed across days)
              - subscribersGained
              - subscribersLost

            Zernio-synthesized from daily follower snapshots (cross-platform parity):
              - followers_gained
              - followers_lost
        - name: since
          in: query
          schema: { type: string, format: date }
          description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
        - name: until
          in: query
          schema: { type: string, format: date }
          description: |
            End date (YYYY-MM-DD). Defaults to today. YouTube Analytics has a 2-3 day delay,
            so the fetch is internally clamped to 3 days ago; any requested range extending
            beyond that returns zero values for the tail days. The response's dateRange.until
            field reflects your requested value.
        - name: metricType
          in: query
          schema:
            type: string
            enum: [time_series, total_value]
            default: total_value
          description: |
            "total_value" (default) returns aggregated totals.
            "time_series" returns per-day values in the "values" array.
      responses:
        '200':
          description: Channel insights data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstagramAccountInsightsResponse'
        '400':
          description: Bad request (invalid accountId / metrics / metricType / date range, or account is not a YouTube account)
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics add-on required
        '404':
          description: Account not found
        '412':
          description: Missing YouTube Analytics scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/YouTubeScopeMissingResponse'

  /v1/analytics/linkedin/org-aggregate-analytics:
    get:
      operationId: getLinkedInOrgAggregateAnalytics
      tags: [Analytics]
      summary: Get LinkedIn organization page aggregate analytics
      description: |
        Returns aggregate analytics for a LinkedIn organization page. Parallel to
        /v1/accounts/{id}/linkedin-aggregate-analytics (which handles personal accounts only).
        Backed by LinkedIn's organizationalEntityShareStatistics,
        organizationalEntityFollowerStatistics, and organizationPageStatistics endpoints.

        Response shape matches /v1/analytics/instagram/account-insights. Max 89 days,
        defaults to last 30 days. Requires the Analytics add-on.

        Scope requirements: r_organization_social, r_organization_followers, and
        r_organization_admin must all be present on the account. Accounts connected before
        these scopes were included in the OAuth flow will return 412 with a reauth hint.

        Enforced by this endpoint:
          - Page-view metrics accept only metricType=total_value (LinkedIn omits per-day
            segmentation even when the API is called with DAY granularity, so a time-series
            response would be meaningless).
          - Date range capped at 89 days.

        LinkedIn-side platform limits (not re-enforced here, but worth knowing for larger
        ranges in a future release):
          - Follower stats: rolling 12-month window, end must be no later than 2 days ago.
          - Share stats: rolling 12-month window.
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: The Zernio SocialAccount ID for the LinkedIn organization account.
        - name: metrics
          in: query
          schema: { type: string }
          description: |
            Comma-separated list. Defaults to
            "impressions,clicks,engagement_rate,organic_followers_gained,followers_gained,followers_lost".

            Share statistics (support both total_value and time_series):
              - impressions
              - unique_impressions
              - clicks
              - likes
              - comments
              - shares
              - engagement_rate       (0..1, LinkedIn-computed)

            Follower-gain statistics (support total_value and time_series):
              - organic_followers_gained   (per-day organic gains for time_series; sum of organic gains over the range for total_value)
              - paid_followers_gained      (per-day paid gains for time_series; sum of paid gains over the range for total_value)

            Page-view statistics (total_value ONLY - LinkedIn platform limit):
              - page_views_total
              - page_views_overview
              - page_views_careers
              - page_views_jobs
              - page_views_life

            Zernio-synthesized from daily follower snapshots:
              - followers_gained
              - followers_lost
        - name: since
          in: query
          schema: { type: string, format: date }
          description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
        - name: until
          in: query
          schema: { type: string, format: date }
          description: End date (YYYY-MM-DD). Defaults to today.
        - name: metricType
          in: query
          schema:
            type: string
            enum: [time_series, total_value]
            default: total_value
      responses:
        '200':
          description: Organization analytics data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstagramAccountInsightsResponse'
        '400':
          description: |
            Bad request. Common cases:
              - Account is a personal LinkedIn account, not organization (code personal_account_not_supported, use /v1/accounts/{id}/linkedin-aggregate-analytics instead)
              - Invalid metric name, metricType, or date range
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics add-on required
        '403':
          description: |
            Platform error. The authenticated member lacks the required
            ADMINISTRATOR role on the organization. LinkedIn enforces admin-only
            access for all three org statistics endpoints. The error envelope is
            type platform_error, and the raw LinkedIn error is echoed in the
            platformError field.
        '404':
          description: Account not found
        '412':
          description: Missing LinkedIn organization analytics scopes (r_organization_social + r_organization_followers + r_organization_admin)

  /v1/analytics/tiktok/account-insights:
    get:
      operationId: getTikTokAccountInsights
      tags: [Analytics]
      summary: Get TikTok account-level insights
      description: |
        Returns account-level TikTok insights from /v2/user/info/ (live) plus historical
        time series joined from Zernio's daily snapshotter (AccountStats).

        Response shape matches /v1/analytics/instagram/account-insights. Max 89 days,
        defaults to last 30 days. Requires the Analytics add-on and the user.info.stats
        scope on the account (412 if missing).

        Scope intentionally narrow. TikTok's public API exposes only the four counter
        metrics below. The deep metrics that live in TikTok Studio are NOT available on any
        public TikTok API, even for Business accounts:
          - profile_views
          - account-level impressions / reach
          - follower inflow / outflow breakdown
          - video watch time, average watch time, full-watched rate
          - impression_sources (FYP / Following / Hashtag / Search / Personal profile)

        TikTok's Research API doesn't expose those fields either, and is restricted to
        non-commercial academic use per TikTok's eligibility policy. There is no public
        API workaround. Post-level metrics (views, likes, comments, shares per video) are
        available via /v1/analytics?postId=... from TikTok's /v2/video/query/.
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: The Zernio SocialAccount ID for the TikTok account.
        - name: metrics
          in: query
          schema: { type: string }
          description: |
            Comma-separated list. Defaults to
            "follower_count,likes_count,video_count,followers_gained,followers_lost".

            Live from /v2/user/info/ (requires user.info.stats scope):
              - follower_count  (cumulative; time series joined from AccountStats)
              - following_count (cumulative; time series joined from AccountStats.metadata)
              - likes_count     (cumulative; time series joined from AccountStats.metadata)
              - video_count     (cumulative; time series joined from AccountStats.metadata)

            Zernio-synthesized:
              - followers_gained  (sum of positive daily follower deltas)
              - followers_lost    (sum of absolute negative daily deltas)
        - name: since
          in: query
          schema: { type: string, format: date }
          description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
        - name: until
          in: query
          schema: { type: string, format: date }
          description: End date (YYYY-MM-DD). Defaults to today.
        - name: metricType
          in: query
          schema:
            type: string
            enum: [time_series, total_value]
            default: total_value
          description: |
            "total_value" returns the latest cumulative counter value.
            "time_series" returns daily values joined from AccountStats snapshots.
      responses:
        '200':
          description: Account insights data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstagramAccountInsightsResponse'
        '400':
          description: Bad request (invalid accountId / metrics / metricType / date range, or account is not a TikTok account)
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics add-on required
        '404':
          description: Account not found
        '412':
          description: Missing user.info.stats scope

  /v1/analytics/youtube/daily-views:
    get:
      operationId: getYouTubeDailyViews
      tags: [Analytics]
      summary: Get YouTube daily views
      description: |
        Returns daily view counts for a YouTube video including views, watch time, and subscriber changes.
        Requires yt-analytics.readonly scope (re-authorization may be needed). Data has a 2-3 day delay. Max 90 days, defaults to last 30 days.
      parameters:
        - name: videoId
          in: query
          required: true
          schema: { type: string }
          description: The YouTube video ID (e.g., "dQw4w9WgXcQ")
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: The Zernio account ID for the YouTube account
        - name: startDate
          in: query
          schema: { type: string, format: date }
          description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
        - name: endDate
          in: query
          schema: { type: string, format: date }
          description: End date (YYYY-MM-DD). Defaults to 3 days ago (YouTube data latency).
      responses:
        '200':
          description: Daily views breakdown
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/YouTubeDailyViewsResponse'
              examples:
                success:
                  summary: Successful response with daily views
                  value:
                    success: true
                    videoId: "dQw4w9WgXcQ"
                    dateRange:
                      startDate: "2025-01-01"
                      endDate: "2025-01-12"
                    totalViews: 12345
                    dailyViews:
                      - date: "2025-01-12"
                        views: 1234
                        estimatedMinutesWatched: 567.5
                        averageViewDuration: 45.2
                        subscribersGained: 10
                        subscribersLost: 2
                        likes: 89
                        comments: 12
                        shares: 5
                      - date: "2025-01-11"
                        views: 987
                        estimatedMinutesWatched: 432.1
                        averageViewDuration: 43.8
                        subscribersGained: 8
                        subscribersLost: 1
                        likes: 67
                        comments: 8
                        shares: 3
                    lastSyncedAt: "2025-01-15T12:00:00Z"
                    scopeStatus:
                      hasAnalyticsScope: true
        '400':
          description: Bad request (missing or invalid parameters)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
              examples:
                missingVideoId:
                  value:
                    error: "videoId is required"
                invalidDate:
                  value:
                    error: "Invalid startDate format. Use YYYY-MM-DD."
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics add-on required
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Analytics add-on required" }
                  code: { type: string, example: "analytics_addon_required" }
        '403':
          description: Access denied to this account
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Access denied to this account" }
        '412':
          description: Missing YouTube Analytics scope
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/YouTubeScopeMissingResponse'
              examples:
                scopeMissing:
                  summary: YouTube Analytics scope not granted
                  value:
                    success: false
                    error: "To access daily video analytics, please reconnect your YouTube account to grant the required permissions."
                    code: "youtube_analytics_scope_missing"
                    scopeStatus:
                      hasAnalyticsScope: false
                      requiresReauthorization: true
                      reauthorizeUrl: "https://accounts.google.com/o/oauth2/auth?client_id=..."
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: false }
                  error: { type: string }

  /v1/analytics/facebook/page-insights:
    get:
      operationId: getFacebookPageInsights
      tags: [Analytics]
      summary: Get Facebook Page insights
      description: |
        Returns page-level Facebook insights (media views, views, post engagements, video metrics,
        follower counts). Response shape matches /v1/analytics/instagram/account-insights so the
        same client handling works across platforms.

        Metric names track the current (post-November 2025) Meta Graph API. The legacy
        page_impressions / page_fans / page_fan_adds / page_fan_removes metrics were deprecated
        by Meta on November 15, 2025 and are NOT accepted by this endpoint. Use the replacements
        below. Because Meta did not provide direct adds/removes replacements, Zernio synthesizes
        followers_gained / followers_lost from the daily follower snapshotter.

        Max 89 days, defaults to last 30 days. Requires the Analytics add-on.
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: The Zernio SocialAccount ID for the connected Facebook Page.
        - name: metrics
          in: query
          schema: { type: string }
          description: |
            Comma-separated list of metrics. Defaults to
            "page_media_view,page_post_engagements,page_follows,followers_gained,followers_lost".

            Live Meta metrics (current names, post-Nov-2025):
              - page_media_view       (replaces deprecated page_impressions)
              - page_views_total
              - page_post_engagements
              - page_video_views
              - page_video_view_time
              - page_follows          (replaces deprecated page_fans)

            Zernio-synthesized from daily follower snapshots (filling the Nov-2025 gap
            left by the page_fan_adds / page_fan_removes deprecation):
              - followers_gained
              - followers_lost
        - name: since
          in: query
          schema: { type: string, format: date }
          description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
        - name: until
          in: query
          schema: { type: string, format: date }
          description: End date (YYYY-MM-DD). Defaults to today.
        - name: metricType
          in: query
          schema:
            type: string
            enum: [time_series, total_value]
            default: total_value
          description: |
            "total_value" (default) returns aggregated totals only.
            "time_series" returns daily values in the "values" array.
      responses:
        '200':
          description: Page insights data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstagramAccountInsightsResponse'
              examples:
                timeSeries:
                  summary: Time series with computed follower deltas
                  value:
                    success: true
                    accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
                    platform: "facebook"
                    dateRange: { since: "2026-03-01", until: "2026-03-22" }
                    metricType: "time_series"
                    metrics:
                      page_media_view:
                        total: 125000
                        values:
                          - { date: "2026-03-01", value: 5400 }
                          - { date: "2026-03-02", value: 4820 }
                      followers_gained:
                        total: 142
                        values:
                          - { date: "2026-03-01", value: 7 }
                          - { date: "2026-03-02", value: 5 }
                      followers_lost:
                        total: 23
                        values:
                          - { date: "2026-03-01", value: 1 }
                          - { date: "2026-03-02", value: 0 }
                    dataDelay: "Meta page insights may be delayed up to 24 hours. Metrics reflect the current (post-November 2025) Graph API names."
        '400':
          description: |
            Bad request. Common cases:
              - Requested a deprecated metric (page_impressions, page_fans, page_fan_adds, page_fan_removes) - use current names instead
              - Account has no Page selected (metadata.pageAccessToken missing)
              - Invalid accountId / metrics / metricType / date range
              - Account is not a Facebook account
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics add-on required
        '404':
          description: Account not found

  /v1/analytics/instagram/account-insights:
    get:
      operationId: getInstagramAccountInsights
      tags: [Analytics]
      summary: Get Instagram insights
      description: |
        Returns account-level Instagram insights such as reach, views, accounts engaged, and total interactions.
        These metrics reflect the entire account's performance across all content surfaces (feed, stories, explore, profile),
        and are fundamentally different from post-level metrics. Data may be delayed up to 48 hours.
        Max 90 days, defaults to last 30 days. Requires the Analytics add-on.
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: The Zernio SocialAccount ID for the Instagram account
        - name: metrics
          in: query
          schema: { type: string }
          description: |
            Comma-separated list of metrics. Defaults to "reach,views,accounts_engaged,total_interactions".
            Valid metrics: reach, views, accounts_engaged, total_interactions, comments, likes, saves, shares,
            replies, reposts, follows_and_unfollows, profile_links_taps.
            Note: only "reach" supports metricType=time_series. All other metrics (including
            follows_and_unfollows) are total_value only. This is an Instagram Graph API limitation,
            not a Zernio limitation - the IG API does not return time-series data for these metrics.
            For a daily running follower count, use /v1/analytics/instagram/follower-history instead.
        - name: since
          in: query
          schema: { type: string, format: date }
          description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
        - name: until
          in: query
          schema: { type: string, format: date }
          description: End date (YYYY-MM-DD). Defaults to today.
        - name: metricType
          in: query
          schema:
            type: string
            enum: [time_series, total_value]
            default: total_value
          description: |
            "total_value" (default) returns aggregated totals and supports breakdowns.
            "time_series" returns daily values but only works with the "reach" metric.
        - name: breakdown
          in: query
          schema: { type: string }
          description: |
            Breakdown dimension (only valid with metricType=total_value).
            Valid values depend on the metric: media_product_type, follow_type, follower_type, contact_button_type.
      responses:
        '200':
          description: Account insights data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstagramAccountInsightsResponse'
              examples:
                timeSeries:
                  summary: Time series response with daily values
                  value:
                    success: true
                    accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
                    platform: "instagram"
                    dateRange:
                      since: "2026-03-01"
                      until: "2026-03-22"
                    metricType: "time_series"
                    metrics:
                      reach:
                        total: 12500
                        values:
                          - date: "2026-03-01"
                            value: 420
                          - date: "2026-03-02"
                            value: 385
                      views:
                        total: 45000
                        values:
                          - date: "2026-03-01"
                            value: 1520
                          - date: "2026-03-02"
                            value: 1380
                    dataDelay: "Data may be delayed up to 48 hours"
                totalValueWithBreakdown:
                  summary: Total value response with media type breakdown
                  value:
                    success: true
                    accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
                    platform: "instagram"
                    dateRange:
                      since: "2026-03-01"
                      until: "2026-03-22"
                    metricType: "total_value"
                    breakdown: "media_product_type"
                    metrics:
                      reach:
                        total: 12500
                        breakdowns:
                          - dimension: "FEED"
                            value: 5000
                          - dimension: "REELS"
                            value: 7500
                    dataDelay: "Data may be delayed up to 48 hours"
        '400':
          description: Bad request (invalid parameters)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
              examples:
                invalidMetric:
                  value:
                    error: "Invalid metrics: impressions"
                    validMetrics: ["accounts_engaged", "comments", "follows_and_unfollows", "likes", "profile_links_taps", "reach", "replies", "reposts", "saves", "shares", "total_interactions", "views"]
                breakdownWithTimeSeries:
                  value:
                    error: "Breakdowns are only supported with metricType=total_value"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics add-on required
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Analytics add-on required" }
                  code: { type: string, example: "analytics_addon_required" }
        '403':
          description: Access denied to this account
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Access denied to this account" }
        '404':
          description: Account not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Account not found" }

  /v1/analytics/instagram/follower-history:
    get:
      operationId: getInstagramFollowerHistory
      tags: [Analytics]
      summary: Get Instagram follower history
      description: |
        Returns a daily running Instagram follower count time series, served from Zernio's
        cross-platform daily snapshotter. Exists because Meta removed follower_count from
        the /insights endpoint in Graph API v22+ and never exposed a historical daily series
        via any public API.

        Response envelope matches /v1/analytics/instagram/account-insights so the same client
        handling works. Max 89 days, defaults to last 30 days. Requires the Analytics add-on.
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: The Zernio SocialAccount ID for the Instagram account.
        - name: metrics
          in: query
          schema: { type: string }
          description: |
            Comma-separated list. Defaults to "follower_count,followers_gained,followers_lost".
              - follower_count   : per-day raw follower count
              - followers_gained : sum of positive daily deltas
              - followers_lost   : sum of absolute negative daily deltas
        - name: since
          in: query
          schema: { type: string, format: date }
          description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
        - name: until
          in: query
          schema: { type: string, format: date }
          description: End date (YYYY-MM-DD). Defaults to today.
        - name: metricType
          in: query
          schema:
            type: string
            enum: [time_series, total_value]
            default: total_value
          description: |
            "total_value" returns aggregated totals (latest for follower_count, sum for gained/lost).
            "time_series" returns per-day values in the "values" array.
      responses:
        '200':
          description: Follower history data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstagramAccountInsightsResponse'
        '400':
          description: Bad request (invalid accountId / metrics / date range, or account is not an Instagram account)
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics add-on required
        '404':
          description: Account not found

  /v1/analytics/instagram/demographics:
    get:
      operationId: getInstagramDemographics
      tags: [Analytics]
      summary: Get Instagram demographics
      description: |
        Returns audience demographic insights for an Instagram account, broken down by age, city, country, and/or gender.
        Requires at least 100 followers. Returns top 45 entries per dimension.
        Data may be delayed up to 48 hours. Requires the Analytics add-on.
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: The Zernio SocialAccount ID for the Instagram account
        - name: metric
          in: query
          schema:
            type: string
            enum: [follower_demographics, engaged_audience_demographics]
            default: follower_demographics
          description: |
            "follower_demographics" for follower audience data, or "engaged_audience_demographics" for engaged viewers.
        - name: breakdown
          in: query
          schema: { type: string }
          description: |
            Comma-separated list of demographic dimensions: age, city, country, gender.
            Defaults to all four if omitted.
        - name: timeframe
          in: query
          schema:
            type: string
            enum: [this_week, this_month]
            default: this_month
          description: |
            Time period for demographic data. Defaults to "this_month".
      responses:
        '200':
          description: Demographic insights data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/InstagramDemographicsResponse'
              examples:
                allBreakdowns:
                  summary: All four demographic breakdowns
                  value:
                    success: true
                    accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
                    platform: "instagram"
                    metric: "follower_demographics"
                    timeframe: "last_30_days"
                    demographics:
                      age:
                        - dimension: "25-34"
                          value: 4500
                        - dimension: "18-24"
                          value: 3200
                      gender:
                        - dimension: "M"
                          value: 3000
                        - dimension: "F"
                          value: 4800
                      city:
                        - dimension: "New York, New York"
                          value: 800
                        - dimension: "Los Angeles, California"
                          value: 650
                      country:
                        - dimension: "US"
                          value: 5000
                        - dimension: "GB"
                          value: 1200
                    note: "Demographics show top 45 entries per dimension. Requires 100+ followers."
        '400':
          description: Bad request (invalid parameters)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
              examples:
                invalidBreakdown:
                  value:
                    error: "Invalid breakdowns: location"
                    validBreakdowns: ["age", "city", "country", "gender"]
                insufficientFollowers:
                  value:
                    success: false
                    error: "Demographic insights require at least 100 followers."
                    code: "instagram_insufficient_followers"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics add-on required
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Analytics add-on required" }
                  code: { type: string, example: "analytics_addon_required" }
        '403':
          description: Access denied to this account
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Access denied to this account" }
        '404':
          description: Account not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Account not found" }

  /v1/analytics/youtube/demographics:
    get:
      operationId: getYouTubeDemographics
      tags: [Analytics]
      summary: Get YouTube demographics
      description: |
        Returns audience demographic insights for a YouTube channel, broken down by age, gender, and/or country.
        Age and gender values are viewer percentages (0-100). Country values are view counts.
        Data is based on signed-in viewers only, with a 2-3 day delay. Requires the Analytics add-on.
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: The Zernio SocialAccount ID for the YouTube account
        - name: breakdown
          in: query
          schema: { type: string }
          description: |
            Comma-separated list of demographic dimensions: age, gender, country.
            Defaults to all three if omitted.
        - name: startDate
          in: query
          schema: { type: string, format: date }
          description: |
            Start date in YYYY-MM-DD format. Defaults to 90 days ago.
        - name: endDate
          in: query
          schema: { type: string, format: date }
          description: |
            End date in YYYY-MM-DD format. Defaults to 3 days ago (YouTube data latency).
      responses:
        '200':
          description: Demographic insights data
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/YouTubeDemographicsResponse'
              examples:
                allBreakdowns:
                  summary: All three demographic breakdowns
                  value:
                    success: true
                    accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
                    platform: "youtube"
                    demographics:
                      age:
                        - dimension: "25-34"
                          value: 28.5
                        - dimension: "18-24"
                          value: 22.1
                      gender:
                        - dimension: "male"
                          value: 62.3
                        - dimension: "female"
                          value: 35.8
                      country:
                        - dimension: "US"
                          value: 12000
                        - dimension: "GB"
                          value: 3500
                    dateRange:
                      startDate: "2026-01-01"
                      endDate: "2026-03-31"
                    note: "Age/gender values are viewer percentages (0-100). Country values are view counts. Data based on signed-in viewers only, with 2-3 day delay."
        '400':
          description: Bad request (invalid parameters or not a YouTube account)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics add-on required
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Analytics add-on required" }
                  code: { type: string, example: "analytics_addon_required" }
        '403':
          description: Access denied to this account
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Access denied to this account" }
        '404':
          description: Account not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Account not found" }
        '412':
          description: YouTube Analytics scope not granted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: false }
                  error: { type: string }
                  code: { type: string, example: "youtube_analytics_scope_missing" }
                  scopeStatus:
                    type: object
                    properties:
                      hasAnalyticsScope: { type: boolean, example: false }
                      requiresReauthorization: { type: boolean, example: true }
                      reauthorizeUrl: { type: string }

  /v1/analytics/daily-metrics:
    get:
      operationId: getDailyMetrics
      tags: [Analytics]
      summary: Get daily aggregated metrics
      description: |
        Returns daily aggregated analytics metrics and a per-platform breakdown.
        Each day includes post count, platform distribution, and summed metrics (impressions, reach, likes, comments, shares, saves, clicks, views).
        Defaults to the last 180 days. Requires the Analytics add-on.
      parameters:
        - name: platform
          in: query
          schema: { type: string }
          description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms.
        - name: profileId
          in: query
          schema: { type: string }
          description: Filter by profile ID. Omit for all profiles.
        - name: accountId
          in: query
          schema: { type: string }
          description: Filter by social account ID
        - name: fromDate
          in: query
          schema: { type: string, format: date-time }
          description: Inclusive start date (ISO 8601). Defaults to 180 days ago.
        - name: toDate
          in: query
          schema: { type: string, format: date-time }
          description: Inclusive end date (ISO 8601). Defaults to now.
        - name: source
          in: query
          schema:
            type: string
            enum: [all, late, external]
            default: all
          description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.
      responses:
        '200':
          description: Daily metrics and platform breakdown
          content:
            application/json:
              schema:
                type: object
                properties:
                  dailyData:
                    type: array
                    items:
                      type: object
                      properties:
                        date: { type: string, example: "2025-12-01" }
                        postCount: { type: integer, example: 3 }
                        platforms:
                          type: object
                          additionalProperties: { type: integer }
                          example: { instagram: 2, twitter: 1 }
                        metrics:
                          type: object
                          properties:
                            impressions: { type: integer }
                            reach: { type: integer }
                            likes: { type: integer }
                            comments: { type: integer }
                            shares: { type: integer }
                            saves: { type: integer }
                            clicks: { type: integer }
                            views: { type: integer }
                  platformBreakdown:
                    type: array
                    items:
                      type: object
                      properties:
                        platform: { type: string, example: "instagram" }
                        postCount: { type: integer, example: 142 }
                        impressions: { type: integer }
                        reach: { type: integer }
                        likes: { type: integer }
                        comments: { type: integer }
                        shares: { type: integer }
                        saves: { type: integer }
                        clicks: { type: integer }
                        views: { type: integer }
              examples:
                success:
                  value:
                    dailyData:
                      - date: "2025-12-01"
                        postCount: 3
                        platforms: { instagram: 2, twitter: 1 }
                        metrics:
                          impressions: 4520
                          reach: 3200
                          likes: 312
                          comments: 45
                          shares: 28
                          saves: 67
                          clicks: 89
                          views: 1560
                    platformBreakdown:
                      - platform: "instagram"
                        postCount: 142
                        impressions: 89400
                        reach: 62100
                        likes: 8930
                        comments: 1204
                        shares: 567
                        saves: 2103
                        clicks: 3402
                        views: 45200
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics add-on required
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Analytics add-on required" }
                  code: { type: string, example: "analytics_addon_required" }

  /v1/analytics/best-time:
    get:
      operationId: getBestTimeToPost
      tags: [Analytics]
      summary: Get best times to post
      description: |
        Returns the best times to post based on historical engagement data.
        Groups all published posts by day of week and hour (UTC), calculating average engagement per slot.
        Use this to auto-schedule posts at optimal times. Requires the Analytics add-on.
      parameters:
        - name: platform
          in: query
          schema: { type: string }
          description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms.
        - name: profileId
          in: query
          schema: { type: string }
          description: Filter by profile ID. Omit for all profiles.
        - name: source
          in: query
          schema:
            type: string
            enum: [all, late, external]
            default: all
          description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.
      responses:
        '200':
          description: Best time slots
          content:
            application/json:
              schema:
                type: object
                properties:
                  slots:
                    type: array
                    items:
                      type: object
                      properties:
                        day_of_week: { type: integer, description: "0=Monday, 6=Sunday", minimum: 0, maximum: 6 }
                        hour: { type: integer, description: "Hour in UTC (0-23)", minimum: 0, maximum: 23 }
                        avg_engagement: { type: number, description: "Average engagement (likes + comments + shares + saves)" }
                        post_count: { type: integer, description: "Number of posts in this slot" }
              examples:
                success:
                  value:
                    slots:
                      - day_of_week: 2
                        hour: 18
                        avg_engagement: 510.3
                        post_count: 15
                      - day_of_week: 0
                        hour: 9
                        avg_engagement: 342.5
                        post_count: 12
                      - day_of_week: 4
                        hour: 12
                        avg_engagement: 289.1
                        post_count: 8
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Analytics add-on required
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Analytics add-on required" }
                  requiresAddon: { type: boolean, example: true }

  /v1/analytics/content-decay:
    get:
      operationId: getContentDecay
      tags: [Analytics]
      summary: Get content performance decay
      description: |
        Returns how engagement accumulates over time after a post is published.
        Each bucket shows what percentage of the post's total engagement had been reached by that time window.
        Useful for understanding content lifespan (e.g. "posts reach 78% of total engagement within 24 hours").
        Requires the Analytics add-on.
      parameters:
        - name: platform
          in: query
          schema: { type: string }
          description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms.
        - name: profileId
          in: query
          schema: { type: string }
          description: Filter by profile ID. Omit for all profiles.
        - name: source
          in: query
          schema:
            type: string
            enum: [all, late, external]
            default: all
          description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.
      responses:
        '200':
          description: Content decay buckets
          content:
            application/json:
              schema:
                type: object
                properties:
                  buckets:
                    type: array
                    items:
                      type: object
                      properties:
                        bucket_order: { type: integer, description: "Sort order (0 = earliest, 6 = latest)" }
                        bucket_label: { type: string, description: "Human-readable label" }
                        avg_pct_of_final: { type: number, description: "Average % of final engagement reached (0-100)" }
                        post_count: { type: integer, description: "Number of posts with data in this bucket" }
              examples:
                success:
                  value:
                    buckets:
                      - bucket_order: 0
                        bucket_label: "0-6h"
                        avg_pct_of_final: 45.2
                        post_count: 89
                      - bucket_order: 1
                        bucket_label: "6-12h"
                        avg_pct_of_final: 18.7
                        post_count: 89
                      - bucket_order: 2
                        bucket_label: "12-24h"
                        avg_pct_of_final: 14.1
                        post_count: 85
                      - bucket_order: 3
                        bucket_label: "1-2d"
                        avg_pct_of_final: 9.3
                        post_count: 82
                      - bucket_order: 4
                        bucket_label: "2-7d"
                        avg_pct_of_final: 8.1
                        post_count: 78
                      - bucket_order: 5
                        bucket_label: "7-30d"
                        avg_pct_of_final: 3.8
                        post_count: 64
                      - bucket_order: 6
                        bucket_label: "30d+"
                        avg_pct_of_final: 0.8
                        post_count: 41
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Analytics add-on required
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Analytics add-on required" }
                  requiresAddon: { type: boolean, example: true }

  /v1/analytics/posting-frequency:
    get:
      operationId: getPostingFrequency
      tags: [Analytics]
      summary: Get frequency vs engagement
      description: |
        Returns the correlation between posting frequency (posts per week) and engagement rate, broken down by platform.
        Helps find the optimal posting cadence for each platform. Each row represents a specific (platform, posts_per_week) combination
        with the average engagement rate observed across all weeks matching that frequency.
        Requires the Analytics add-on.
      parameters:
        - name: platform
          in: query
          schema: { type: string }
          description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms.
        - name: profileId
          in: query
          schema: { type: string }
          description: Filter by profile ID. Omit for all profiles.
        - name: source
          in: query
          schema:
            type: string
            enum: [all, late, external]
            default: all
          description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.
      responses:
        '200':
          description: Posting frequency data
          content:
            application/json:
              schema:
                type: object
                properties:
                  frequency:
                    type: array
                    items:
                      type: object
                      properties:
                        platform: { type: string, example: "instagram" }
                        posts_per_week: { type: integer, description: "Number of posts published that week" }
                        avg_engagement_rate: { type: number, description: "Average engagement rate as percentage (0-100)" }
                        avg_engagement: { type: number, description: "Average raw engagement (likes+comments+shares+saves)" }
                        weeks_count: { type: integer, description: "Number of calendar weeks observed at this frequency" }
              examples:
                success:
                  value:
                    frequency:
                      - platform: "instagram"
                        posts_per_week: 2
                        avg_engagement_rate: 44.4
                        avg_engagement: 512
                        weeks_count: 18
                      - platform: "instagram"
                        posts_per_week: 4
                        avg_engagement_rate: 5.9
                        avg_engagement: 203
                        weeks_count: 6
                      - platform: "facebook"
                        posts_per_week: 3
                        avg_engagement_rate: 12.5
                        avg_engagement: 87
                        weeks_count: 10
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Analytics add-on required
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Analytics add-on required" }
                  requiresAddon: { type: boolean, example: true }

  /v1/analytics/post-timeline:
    get:
      operationId: getPostTimeline
      tags: [Analytics]
      summary: Get post analytics timeline
      description: |
        Returns a daily timeline of analytics metrics for a specific post, showing how impressions, likes,
        and other metrics evolved day-by-day since publishing. Each row represents one day of data per platform.
        For multi-platform Zernio posts, returns separate rows for each platform. Requires the Analytics add-on.
      parameters:
        - name: postId
          in: query
          required: true
          schema: { type: string }
          description: |
            The post to fetch timeline for. Accepts an ExternalPost ID, a platformPostId, or a Zernio Post ID.
        - name: fromDate
          in: query
          schema: { type: string, format: date-time }
          description: Start of date range (ISO 8601). Defaults to 90 days ago.
        - name: toDate
          in: query
          schema: { type: string, format: date-time }
          description: End of date range (ISO 8601). Defaults to now.
      responses:
        '200':
          description: Daily analytics timeline
          content:
            application/json:
              schema:
                type: object
                properties:
                  postId:
                    type: string
                    description: The postId that was requested
                  timeline:
                    type: array
                    items:
                      type: object
                      properties:
                        date: { type: string, format: date, description: "Date in YYYY-MM-DD format" }
                        platform: { type: string, description: "Platform name (e.g. instagram, tiktok)" }
                        platformPostId: { type: string, description: "Platform-specific post ID" }
                        impressions: { type: integer, description: "Total impressions on this date" }
                        reach: { type: integer, description: "Total reach on this date" }
                        likes: { type: integer, description: "Total likes on this date" }
                        comments: { type: integer, description: "Total comments on this date" }
                        shares: { type: integer, description: "Total shares on this date" }
                        saves: { type: integer, description: "Total saves on this date" }
                        clicks: { type: integer, description: "Total clicks on this date" }
                        views: { type: integer, description: "Total views on this date" }
              examples:
                single_platform:
                  summary: Single-platform post timeline
                  value:
                    postId: "6507a1b2c3d4e5f6a7b8c9d0"
                    timeline:
                      - date: "2025-01-15"
                        platform: "instagram"
                        platformPostId: "17902345678901234"
                        impressions: 1200
                        reach: 980
                        likes: 45
                        comments: 3
                        shares: 12
                        saves: 8
                        clicks: 25
                        views: 0
                      - date: "2025-01-16"
                        platform: "instagram"
                        platformPostId: "17902345678901234"
                        impressions: 2400
                        reach: 1850
                        likes: 92
                        comments: 7
                        shares: 21
                        saves: 15
                        clicks: 48
                        views: 0
        '400':
          description: Missing required postId parameter
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Missing required parameter: postId" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics add-on required
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Analytics add-on required" }
                  code: { type: string, example: "analytics_addon_required" }
        '403':
          description: Forbidden (post belongs to another user or API key scope violation)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Forbidden" }
        '404':
          description: Post not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Post not found" }

  /v1/analytics/googlebusiness/performance:
    get:
      operationId: getGoogleBusinessPerformance
      tags: [Analytics]
      summary: Get GBP performance metrics
      description: |
        Returns daily performance metrics for a Google Business Profile location.
        Metrics include impressions (Maps/Search, desktop/mobile), website clicks,
        call clicks, direction requests, conversations, bookings, and food orders.
        Data may be delayed 2-3 days. Max 18 months of historical data.
        Requires the Analytics add-on.
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: The Zernio SocialAccount ID for the Google Business Profile account.
        - name: metrics
          in: query
          schema: { type: string }
          description: |
            Comma-separated metric names. Defaults to all available metrics.
            Valid values: BUSINESS_IMPRESSIONS_DESKTOP_MAPS, BUSINESS_IMPRESSIONS_DESKTOP_SEARCH,
            BUSINESS_IMPRESSIONS_MOBILE_MAPS, BUSINESS_IMPRESSIONS_MOBILE_SEARCH,
            BUSINESS_CONVERSATIONS, BUSINESS_DIRECTION_REQUESTS, CALL_CLICKS, WEBSITE_CLICKS,
            BUSINESS_BOOKINGS, BUSINESS_FOOD_ORDERS, BUSINESS_FOOD_MENU_CLICKS
        - name: startDate
          in: query
          schema: { type: string, format: date }
          description: Start date (YYYY-MM-DD). Defaults to 30 days ago. Max 18 months back.
        - name: endDate
          in: query
          schema: { type: string, format: date }
          description: End date (YYYY-MM-DD). Defaults to today.
      responses:
        '200':
          description: Performance metrics with daily time series
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  accountId: { type: string }
                  platform: { type: string, example: "googlebusiness" }
                  dateRange:
                    type: object
                    properties:
                      startDate: { type: string, format: date, example: "2026-03-01" }
                      endDate: { type: string, format: date, example: "2026-03-31" }
                  metrics:
                    type: object
                    description: Each key is a metric name containing total and daily values.
                    additionalProperties:
                      type: object
                      properties:
                        total: { type: integer, description: "Sum of all daily values in the range" }
                        values:
                          type: array
                          items:
                            type: object
                            properties:
                              date: { type: string, format: date }
                              value: { type: integer }
                  dataDelay: { type: string, example: "Data may be delayed 2-3 days" }
              examples:
                performance_data:
                  summary: Performance metrics for a location
                  value:
                    success: true
                    accountId: "69300690f43160a0bc999e07"
                    platform: "googlebusiness"
                    dateRange:
                      startDate: "2026-03-01"
                      endDate: "2026-03-31"
                    metrics:
                      WEBSITE_CLICKS:
                        total: 42
                        values:
                          - date: "2026-03-01"
                            value: 3
                          - date: "2026-03-02"
                            value: 1
                      CALL_CLICKS:
                        total: 7
                        values:
                          - date: "2026-03-01"
                            value: 1
                      BUSINESS_IMPRESSIONS_MOBILE_SEARCH:
                        total: 156
                        values:
                          - date: "2026-03-01"
                            value: 8
                    dataDelay: "Data may be delayed 2-3 days"
        '400':
          description: Invalid parameters
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Invalid metrics: INVALID_METRIC" }
                  validMetrics:
                    type: array
                    items: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics add-on required
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Analytics add-on required" }
                  code: { type: string, example: "analytics_addon_required" }
        '403':
          description: Access denied
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Access denied to this account" }

  /v1/analytics/googlebusiness/search-keywords:
    get:
      operationId: getGoogleBusinessSearchKeywords
      tags: [Analytics]
      summary: Get GBP search keywords
      description: |
        Returns search keywords that triggered impressions for a Google Business Profile location.
        Data is aggregated monthly. Keywords below a minimum impression threshold set by Google are excluded.
        Max 18 months of historical data. Requires the Analytics add-on.
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: The Zernio SocialAccount ID for the Google Business Profile account.
        - name: startMonth
          in: query
          schema: { type: string, pattern: "^\\d{4}-\\d{2}$" }
          description: Start month (YYYY-MM). Defaults to 3 months ago.
        - name: endMonth
          in: query
          schema: { type: string, pattern: "^\\d{4}-\\d{2}$" }
          description: End month (YYYY-MM). Defaults to current month.
      responses:
        '200':
          description: Search keywords with impression counts
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  accountId: { type: string }
                  platform: { type: string, example: "googlebusiness" }
                  monthRange:
                    type: object
                    properties:
                      startMonth: { type: string, example: "2026-01" }
                      endMonth: { type: string, example: "2026-03" }
                  keywords:
                    type: array
                    items:
                      type: object
                      properties:
                        keyword: { type: string, example: "restaurant near me" }
                        impressions: { type: integer, example: 245 }
                  note: { type: string, example: "Keywords below a minimum impression threshold are excluded by Google" }
              examples:
                keywords_data:
                  summary: Search keywords for a location
                  value:
                    success: true
                    accountId: "69300690f43160a0bc999e07"
                    platform: "googlebusiness"
                    monthRange:
                      startMonth: "2026-01"
                      endMonth: "2026-03"
                    keywords:
                      - keyword: "restaurant near me"
                        impressions: 245
                      - keyword: "best tapas barcelona"
                        impressions: 89
                      - keyword: "arbichat"
                        impressions: 34
                    note: "Keywords below a minimum impression threshold are excluded by Google"
        '400':
          description: Invalid parameters
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Invalid startMonth format. Use YYYY-MM." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics add-on required
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Analytics add-on required" }
                  code: { type: string, example: "analytics_addon_required" }
        '403':
          description: Access denied
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Access denied to this account" }

  /v1/account-groups:
    get:
      operationId: listAccountGroups
      tags: [Account Groups]
      summary: List groups
      description: Returns all account groups for the authenticated user, including group names and associated account IDs.
      responses:
        '200':
          description: Groups
          content:
            application/json:
              schema:
                type: object
                properties:
                  groups:
                    type: array
                    items:
                      type: object
                      properties:
                        _id: { type: string }
                        name: { type: string }
                        accountIds:
                          type: array
                          items: { type: string }
              examples:
                example:
                  value:
                    groups:
                      - _id: "6507a1b2c3d4e5f6a7b8c9d0"
                        name: "Marketing Accounts"
                        accountIds:
                          - "64e1f0a9e2b5af0012ab34cd"
                          - "64e1f0a9e2b5af0012ab34ce"
                      - _id: "6507a1b2c3d4e5f6a7b8c9d1"
                        name: "Personal Brand"
                        accountIds:
                          - "64e1f0a9e2b5af0012ab34cf"
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createAccountGroup
      tags: [Account Groups]
      summary: Create group
      description: Creates a new account group with a name and a list of social account IDs.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, accountIds]
              properties:
                name: { type: string }
                accountIds:
                  type: array
                  items: { type: string }
            example:
              name: "Marketing Accounts"
              accountIds:
                - "64e1f0a9e2b5af0012ab34cd"
                - "64e1f0a9e2b5af0012ab34ce"
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  group:
                    type: object
                    properties:
                      _id: { type: string }
                      name: { type: string }
                      accountIds:
                        type: array
                        items: { type: string }
              example:
                message: "Account group created successfully"
                group:
                  _id: "6507a1b2c3d4e5f6a7b8c9d0"
                  name: "Marketing Accounts"
                  accountIds:
                    - "64e1f0a9e2b5af0012ab34cd"
                    - "64e1f0a9e2b5af0012ab34ce"
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { description: Group name already exists }
  /v1/account-groups/{groupId}:
    put:
      operationId: updateAccountGroup
      tags: [Account Groups]
      summary: Update group
      description: Updates the name or account list of an existing group. You can rename the group, change its accounts, or both.
      parameters:
        - name: groupId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                accountIds:
                  type: array
                  items: { type: string }
            example:
              name: "Updated Marketing Accounts"
              accountIds:
                - "64e1f0a9e2b5af0012ab34cd"
                - "64e1f0a9e2b5af0012ab34ce"
                - "64e1f0a9e2b5af0012ab34cf"
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  group:
                    type: object
              example:
                message: "Account group updated successfully"
                group:
                  _id: "6507a1b2c3d4e5f6a7b8c9d0"
                  name: "Updated Marketing Accounts"
                  accountIds:
                    - "64e1f0a9e2b5af0012ab34cd"
                    - "64e1f0a9e2b5af0012ab34ce"
                    - "64e1f0a9e2b5af0012ab34cf"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: Group name already exists }
    delete:
      operationId: deleteAccountGroup
      tags: [Account Groups]
      summary: Delete group
      description: Permanently deletes an account group. The accounts themselves are not affected.
      parameters:
        - name: groupId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
              example:
                message: "Account group deleted successfully"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/media/presign:
    post:
      operationId: getMediaPresignedUrl
      tags: [Media]
      summary: Get upload URL
      description: Get a presigned URL to upload files directly to cloud storage (up to 5GB). Returns an uploadUrl and publicUrl. PUT your file to the uploadUrl, then use the publicUrl in your posts.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [filename, contentType]
              properties:
                filename:
                  type: string
                  description: Name of the file to upload
                  example: "my-video.mp4"
                contentType:
                  type: string
                  description: MIME type of the file
                  enum:
                    - image/jpeg
                    - image/jpg
                    - image/png
                    - image/webp
                    - image/gif
                    - video/mp4
                    - video/mpeg
                    - video/quicktime
                    - video/avi
                    - video/x-msvideo
                    - video/webm
                    - video/x-m4v
                    - application/pdf
                  example: "video/mp4"
                size:
                  type: integer
                  description: Optional file size in bytes for pre-validation (max 5GB)
                  example: 15234567
      responses:
        '200':
          description: Presigned URL generated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  uploadUrl:
                    type: string
                    format: uri
                    description: Presigned URL to PUT your file to (expires in 1 hour)
                  publicUrl:
                    type: string
                    format: uri
                    description: Public URL where the file will be accessible after upload
                  key:
                    type: string
                    description: Storage key/path of the file
                  type:
                    type: string
                    enum: [image, video, document]
                    description: Detected file type based on content type
              example:
                uploadUrl: "<presigned-upload-url>"
                publicUrl: "https://media.zernio.com/temp/1234567890_abc123_my-video.mp4"
                key: "temp/1234567890_abc123_my-video.mp4"
                type: "video"
        '400':
          description: Invalid request (missing filename, contentType, or unsupported content type)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
              examples:
                missing_filename:
                  value: { error: "filename and contentType are required" }
                invalid_type:
                  value: { error: "Content type not allowed: text/plain" }
                file_too_large:
                  value: { error: "File too large. Maximum size is 5GB." }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/reddit/search:
    get:
      operationId: searchReddit
      tags: [Reddit Search]
      summary: Search posts
      description: Search Reddit posts using a connected account. Optionally scope to a specific subreddit.
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
        - name: subreddit
          in: query
          schema: { type: string }
        - name: q
          in: query
          required: true
          schema: { type: string }
        - name: restrict_sr
          in: query
          schema: { type: string, enum: ['0','1'] }
        - name: sort
          in: query
          schema: { type: string, enum: [relevance, hot, top, new, comments], default: new }
        - name: limit
          in: query
          schema: { type: integer, default: 25, maximum: 100 }
        - name: after
          in: query
          schema: { type: string }
      responses:
        '200':
          description: Search results
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/RedditPost'
                  after: { type: string, nullable: true }
                  before: { type: string, nullable: true }
              example:
                items:
                  - id: "1abc234"
                    fullname: "t3_1abc234"
                    title: "How to grow on social media in 2025"
                    selftext: "Here are my tips..."
                    author: "marketingpro"
                    subreddit: "socialmedia"
                    url: "https://www.reddit.com/r/socialmedia/comments/1abc234/"
                    permalink: "https://www.reddit.com/r/socialmedia/comments/1abc234/how_to_grow/"
                    score: 156
                    numComments: 42
                    createdUtc: 1730000000
                    over18: false
                    stickied: false
                    flairText: null
                    isGallery: false
                after: "t3_1abc234"
                before: null
        '400': { description: Missing params }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
  /v1/reddit/feed:
    get:
      operationId: getRedditFeed
      tags: [Reddit Search]
      summary: Get subreddit feed
      description: Fetch posts from a subreddit feed. Supports sorting, time filtering, and cursor-based pagination.
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
        - name: subreddit
          in: query
          schema: { type: string }
        - name: sort
          in: query
          schema: { type: string, enum: [hot, new, top, rising], default: hot }
        - name: limit
          in: query
          schema: { type: integer, default: 25, maximum: 100 }
        - name: after
          in: query
          schema: { type: string }
        - name: t
          in: query
          schema: { type: string, enum: [hour, day, week, month, year, all] }
      responses:
        '200':
          description: Feed items
          content:
            application/json:
              schema:
                type: object
                properties:
                  items:
                    type: array
                    items:
                      $ref: '#/components/schemas/RedditPost'
                  after: { type: string, nullable: true }
                  before: { type: string, nullable: true }
              example:
                items:
                  - id: "1xyz789"
                    fullname: "t3_1xyz789"
                    title: "Top marketing trends this week"
                    author: "trendwatcher"
                    subreddit: "marketing"
                    url: "https://www.reddit.com/r/marketing/comments/1xyz789/"
                    permalink: "https://www.reddit.com/r/marketing/comments/1xyz789/top_marketing_trends/"
                    score: 892
                    numComments: 134
                    createdUtc: 1730100000
                    over18: false
                    stickied: false
                    flairText: null
                    isGallery: false
                  - id: "1def456"
                    fullname: "t3_1def456"
                    title: "Check out my grow setup"
                    author: "growthexpert"
                    subreddit: "gardening"
                    url: "https://www.reddit.com/gallery/1def456"
                    permalink: "https://www.reddit.com/r/gardening/comments/1def456/check_out_my_grow_setup/"
                    score: 567
                    numComments: 89
                    createdUtc: 1730050000
                    over18: false
                    stickied: false
                    flairText: null
                    isGallery: true
                    galleryImages:
                      - "https://i.redd.it/abc123.jpg"
                      - "https://i.redd.it/def456.jpg"
                after: "t3_1def456"
                before: null
        '400': { description: Missing params }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
  /v1/usage-stats:
    get:
      operationId: getUsageStats
      tags: [Usage]
      summary: Get plan and usage stats
      description: Returns the current plan name, billing period, plan limits, and usage counts.
      responses:
        '200':
          description: Usage stats
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageStats'
              example:
                planName: "Pro"
                billingPeriod: "monthly"
                signupDate: "2024-01-15T10:30:00Z"
                billingAnchorDay: 15
                limits:
                  uploads: 500
                  profiles: 10
                usage:
                  uploads: 127
                  profiles: 3
                  lastReset: "2024-11-01T00:00:00Z"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/posts:
    get:
      operationId: listPosts
      tags: [Posts]
      summary: List posts
      description: Returns a paginated list of posts. Published posts include platformPostUrl with the public URL on each platform.
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - $ref: '#/components/parameters/LimitParam'
        - name: status
          in: query
          schema: { type: string, enum: [draft, scheduled, published, failed] }
        - name: platform
          in: query
          schema: { type: string, example: twitter }
        - name: profileId
          in: query
          schema: { type: string }
        - name: createdBy
          in: query
          schema: { type: string }
        - name: dateFrom
          in: query
          schema: { type: string, format: date }
        - name: dateTo
          in: query
          schema: { type: string, format: date }
        - name: includeHidden
          in: query
          schema: { type: boolean, default: false }
        - name: search
          in: query
          schema: { type: string }
          description: Search posts by text content.
        - name: sortBy
          in: query
          schema:
            type: string
            enum: [scheduled-desc, scheduled-asc, created-desc, created-asc, status, platform]
            default: scheduled-desc
          description: Sort order for results.
        - name: accountId
          in: query
          required: false
          schema: { type: string }
          description: Filter posts to those published via a specific social account (24-char hex ObjectId).
      responses:
        '200':
          description: Paginated posts
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostsListResponse'
              examples:
                scheduledPost:
                  summary: Scheduled post (pending publish)
                  value:
                    posts:
                      - _id: "65f1c0a9e2b5af0012ab34cd"
                        title: "Launch post"
                        content: "We just launched!"
                        status: "scheduled"
                        scheduledFor: "2024-11-01T10:00:00Z"
                        timezone: "UTC"
                        platforms:
                          - platform: "twitter"
                            accountId:
                              _id: "64e1f0..."
                              platform: "twitter"
                              username: "@acme"
                              displayName: "Acme Corp"
                              isActive: true
                            status: "pending"
                        tags: ["launch"]
                        createdAt: "2024-10-01T12:00:00Z"
                        updatedAt: "2024-10-01T12:00:00Z"
                    pagination:
                      page: 1
                      limit: 10
                      total: 1
                      pages: 1
                publishedPost:
                  summary: Published post with platformPostUrl
                  value:
                    posts:
                      - _id: "65f1c0a9e2b5af0012ab34cd"
                        title: "Launch post"
                        content: "We just launched!"
                        status: "published"
                        scheduledFor: "2024-11-01T10:00:00Z"
                        publishedAt: "2024-11-01T10:00:05Z"
                        timezone: "UTC"
                        platforms:
                          - platform: "twitter"
                            accountId:
                              _id: "64e1f0a9e2b5af0012ab34de"
                              platform: "twitter"
                              username: "@acmecorp"
                              displayName: "Acme Corporation"
                              isActive: true
                            status: "published"
                            publishedAt: "2024-11-01T10:00:05Z"
                            platformPostId: "1852634789012345678"
                            platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678"
                          - platform: "linkedin"
                            accountId:
                              _id: "64e1f0a9e2b5af0012ab34ef"
                              platform: "linkedin"
                              username: "acme-corporation"
                              displayName: "Acme Corporation"
                              isActive: true
                            status: "published"
                            publishedAt: "2024-11-01T10:00:06Z"
                            platformPostId: "urn:li:share:7123456789012345678"
                            platformPostUrl: "https://www.linkedin.com/feed/update/urn:li:share:7123456789012345678"
                        tags: ["launch"]
                        createdAt: "2024-10-01T12:00:00Z"
                        updatedAt: "2024-11-01T10:00:06Z"
                    pagination:
                      page: 1
                      limit: 10
                      total: 1
                      pages: 1
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createPost
      tags: [Posts]
      summary: Create post
      description: |
        Create and optionally publish a post. Immediate posts (publishNow: true) include platformPostUrl in the response.
        Content is optional when media is attached or all platforms have customContent. See each platform's schema for media constraints.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                content:
                  type: string
                  description: Post caption/text. Optional when media is attached or all platforms have customContent. Required for text-only posts.
                mediaItems:
                  type: array
                  items:
                    type: object
                    properties:
                      type: { type: string, enum: [image, video, gif, document] }
                      url: { type: string, format: uri }
                platforms:
                  type: array
                  description: Target platforms and accounts for this post. Required for non-draft posts (returns 400 if empty). Drafts can omit platforms.
                  items:
                    type: object
                    properties:
                      platform: { type: string, example: twitter }
                      accountId: { type: string }
                      customContent:
                        type: string
                        description: Platform-specific text override. When set, this content is used instead of the top-level post content for this platform. Useful for tailoring captions per platform (e.g. keeping tweets under 280 characters).
                      customMedia:
                        type: array
                        items:
                          type: object
                          properties:
                            type: { type: string, enum: [image, video, gif, document] }
                            url: { type: string, format: uri }
                      scheduledFor:
                        type: string
                        format: date-time
                        description: Optional per-platform scheduled time override. When omitted, the top-level scheduledFor is used.
                      platformSpecificData:
                        oneOf:
                          - $ref: '#/components/schemas/TwitterPlatformData'
                          - $ref: '#/components/schemas/ThreadsPlatformData'
                          - $ref: '#/components/schemas/FacebookPlatformData'
                          - $ref: '#/components/schemas/InstagramPlatformData'
                          - $ref: '#/components/schemas/LinkedInPlatformData'
                          - $ref: '#/components/schemas/PinterestPlatformData'
                          - $ref: '#/components/schemas/YouTubePlatformData'
                          - $ref: '#/components/schemas/GoogleBusinessPlatformData'
                          - $ref: '#/components/schemas/TikTokPlatformData'
                          - $ref: '#/components/schemas/TelegramPlatformData'
                          - $ref: '#/components/schemas/SnapchatPlatformData'
                          - $ref: '#/components/schemas/RedditPlatformData'
                          - $ref: '#/components/schemas/BlueskyPlatformData'
                          - $ref: '#/components/schemas/DiscordPlatformData'
                scheduledFor: { type: string, format: date-time }
                publishNow: { type: boolean, default: false }
                isDraft:
                  type: boolean
                  default: false
                  description: When true, saves the post as a draft. When none of scheduledFor, publishNow, or queuedFromProfile are provided, the post defaults to draft automatically.
                timezone: { type: string, default: UTC }
                tags:
                  type: array
                  description: "Tags/keywords. YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates auto-removed."
                  items: { type: string }
                hashtags:
                  type: array
                  items: { type: string }
                mentions:
                  type: array
                  description: "Stored for reference only. This field does NOT automatically create @mentions when publishing. For LinkedIn @mentions, use the /v1/accounts/{accountId}/linkedin-mentions endpoint to resolve profile URLs to URNs, then embed the returned mentionFormat directly in the post content field."
                  items: { type: string }
                crosspostingEnabled: { type: boolean, default: true }
                metadata: { type: object, additionalProperties: true }
                tiktokSettings:
                  $ref: '#/components/schemas/TikTokPlatformData'
                  description: Root-level TikTok settings applied to all TikTok platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence.
                facebookSettings:
                  $ref: '#/components/schemas/FacebookPlatformData'
                  description: Root-level Facebook settings applied to all Facebook platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence.
                recycling:
                  $ref: '#/components/schemas/RecyclingConfig'
                queuedFromProfile:
                  type: string
                  description: Profile ID to schedule via queue. When provided without scheduledFor, the post is auto-assigned to the next available slot. Do not call /v1/queue/next-slot and use that time in scheduledFor, as that bypasses queue locking.
                queueId:
                  type: string
                  description: |
                    Specific queue ID to use when scheduling via queue.
                    Only used when queuedFromProfile is also provided.
                    If omitted, uses the profile's default queue.
            examples:
              facebookDraft:
                summary: Facebook draft post (visible in Publishing Tools)
                value:
                  content: "Draft post for review before publishing"
                  platforms:
                    - platform: facebook
                      accountId: "64e1f0a9e2b5af0012ab34cd"
                  publishNow: true
                  facebookSettings:
                    draft: true
              recyclingPost:
                summary: Post with weekly recycling and content variations
                value:
                  content: "Check out our evergreen guide!"
                  platforms:
                    - platform: twitter
                      accountId: "64e1f0a9e2b5af0012ab34cd"
                  scheduledFor: "2025-06-01T10:00:00Z"
                  recycling:
                    gap: 2
                    gapFreq: week
                    expireCount: 6
                    contentVariations:
                      - "Check out our evergreen guide!"
                      - "Don't miss our essential guide!"
                      - "Our most popular guide, updated!"
              tiktokPhotoCarousel:
                summary: TikTok photo carousel (Creator Inbox draft)
                description: |
                  Sends photos to TikTok Creator Inbox as a draft. The creator receives an inbox
                  notification and completes the post via TikTok's editing flow. Uses draft: true
                  which maps to TikTok API post_mode MEDIA_UPLOAD. Note: publish_type is not a
                  supported field; use draft instead.
                value:
                  content: "Check out these photos!"
                  mediaItems:
                    - type: image
                      url: "https://example.com/photo1.jpg"
                    - type: image
                      url: "https://example.com/photo2.jpg"
                  platforms:
                    - platform: tiktok
                      accountId: "64e1f0a9e2b5af0012ab34cd"
                  tiktokSettings:
                    draft: true
                    privacyLevel: "PUBLIC_TO_EVERYONE"
                    allowComment: true
                    photoCoverIndex: 0
                    autoAddMusic: false
                    contentPreviewConfirmed: true
                    expressConsentGiven: true
              tiktokPhotoDirect:
                summary: TikTok photo carousel (direct publish)
                description: |
                  Publishes photos directly to TikTok. With draft omitted or false, the post is
                  published immediately via TikTok API post_mode DIRECT_POST.
                value:
                  content: "Check out these photos!"
                  mediaItems:
                    - type: image
                      url: "https://example.com/photo1.jpg"
                    - type: image
                      url: "https://example.com/photo2.jpg"
                  platforms:
                    - platform: tiktok
                      accountId: "64e1f0a9e2b5af0012ab34cd"
                  tiktokSettings:
                    privacyLevel: "PUBLIC_TO_EVERYONE"
                    allowComment: true
                    photoCoverIndex: 0
                    autoAddMusic: false
                    contentPreviewConfirmed: true
                    expressConsentGiven: true
              tiktokVideo:
                summary: TikTok video post (direct publish)
                value:
                  content: "New video is live!"
                  mediaItems:
                    - type: video
                      url: "https://example.com/video.mp4"
                  platforms:
                    - platform: tiktok
                      accountId: "64e1f0a9e2b5af0012ab34cd"
                  tiktokSettings:
                    privacyLevel: "PUBLIC_TO_EVERYONE"
                    allowComment: true
                    allowDuet: true
                    allowStitch: true
                    commercialContentType: "none"
                    contentPreviewConfirmed: true
                    expressConsentGiven: true
              tiktokVideoDraft:
                summary: TikTok video post (Creator Inbox draft)
                description: |
                  Sends a video to TikTok Creator Inbox as a draft. Video drafts use a dedicated
                  TikTok endpoint (/v2/post/publish/inbox/video/init/) that only accepts source_info,
                  so post_info fields (privacyLevel, allowComment, etc.) are set by the creator
                  during TikTok's editing flow.
                value:
                  content: "New video draft!"
                  mediaItems:
                    - type: video
                      url: "https://example.com/video.mp4"
                  platforms:
                    - platform: tiktok
                      accountId: "64e1f0a9e2b5af0012ab34cd"
                  tiktokSettings:
                    draft: true
                    contentPreviewConfirmed: true
                    expressConsentGiven: true
              multiPlatform:
                summary: Multi-platform post (Twitter + LinkedIn)
                value:
                  content: "We just launched our new product!"
                  mediaItems:
                    - type: image
                      url: "https://example.com/launch.jpg"
                  platforms:
                    - platform: twitter
                      accountId: "64e1f0a9e2b5af0012ab34cd"
                    - platform: linkedin
                      accountId: "64e1f0a9e2b5af0012ab34ef"
                  scheduledFor: "2024-11-01T10:00:00Z"
                  timezone: "America/New_York"
      responses:
        '201':
          description: Post created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostCreateResponse'
              examples:
                scheduled:
                  summary: Scheduled post (URLs populated after publish time)
                  value:
                    post:
                      _id: "65f1c0a9e2b5af0012ab34cd"
                      title: "Launch post"
                      content: "We just launched!"
                      status: "scheduled"
                      scheduledFor: "2024-11-01T10:00:00Z"
                      timezone: "UTC"
                      platforms:
                        - platform: "twitter"
                          accountId:
                            _id: "64e1f0..."
                            platform: "twitter"
                            username: "@acme"
                            displayName: "Acme Corp"
                            isActive: true
                          status: "pending"
                    message: "Post scheduled successfully"
                immediatePublish:
                  summary: Immediate post with publishNow=true (URLs included)
                  value:
                    post:
                      _id: "65f1c0a9e2b5af0012ab34cd"
                      title: "Launch post"
                      content: "We just launched!"
                      status: "published"
                      publishedAt: "2024-11-01T10:00:05Z"
                      timezone: "UTC"
                      platforms:
                        - platform: "twitter"
                          accountId:
                            _id: "64e1f0a9e2b5af0012ab34de"
                            platform: "twitter"
                            username: "@acmecorp"
                            displayName: "Acme Corporation"
                            isActive: true
                          status: "published"
                          publishedAt: "2024-11-01T10:00:05Z"
                          platformPostId: "1852634789012345678"
                          platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678"
                        - platform: "linkedin"
                          accountId:
                            _id: "64e1f0a9e2b5af0012ab34ef"
                            platform: "linkedin"
                            username: "acme-corporation"
                            displayName: "Acme Corporation"
                            isActive: true
                          status: "published"
                          publishedAt: "2024-11-01T10:00:06Z"
                          platformPostId: "urn:li:share:7123456789012345678"
                          platformPostUrl: "https://www.linkedin.com/feed/update/urn:li:share:7123456789012345678"
                    message: "Post published successfully"
                queueScheduled:
                  summary: Post scheduled via queue (using queuedFromProfile)
                  value:
                    post:
                      _id: "65f1c0a9e2b5af0012ab34cd"
                      content: "Scheduled via queue!"
                      status: "scheduled"
                      scheduledFor: "2024-11-01T09:00:00Z"
                      timezone: "America/New_York"
                      queuedFromProfile: "64f0a1b2c3d4e5f6a7b8c9d0"
                      queueId: "64f0a1b2c3d4e5f6a7b8c9d1"
                      platforms:
                        - platform: "linkedin"
                          accountId:
                            _id: "64e1f0..."
                            platform: "linkedin"
                            username: "acme-corp"
                            displayName: "Acme Corp"
                            isActive: true
                          status: "pending"
                    message: "Post scheduled successfully"
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Forbidden
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
        '409':
          description: Duplicate content detected
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "This exact content was already posted to this account within the last 24 hours." }
                  details:
                    type: object
                    properties:
                      accountId: { type: string }
                      platform: { type: string }
                      existingPostId: { type: string }
        '429':
          description: "Rate limit exceeded. Possible causes: API rate limit, velocity limit (15 posts/hour per account), account cooldown, or daily platform limits."
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  details:
                    type: object
                    description: Additional context about the rate limit
          headers:
            Retry-After:
              description: Seconds until the rate limit resets (for API rate limits)
              schema: { type: integer }
            X-RateLimit-Limit:
              description: The rate limit ceiling
              schema: { type: integer }
            X-RateLimit-Remaining:
              description: Requests remaining in current window
              schema: { type: integer }
  /v1/posts/{postId}:
    get:
      operationId: getPost
      tags: [Posts]
      summary: Get post
      description: |
        Fetch a single post by ID. For published posts, this returns platformPostUrl for each platform.
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Post
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostGetResponse'
              examples:
                scheduledPost:
                  summary: Scheduled post (pending)
                  value:
                    post:
                      _id: "65f1c0a9e2b5af0012ab34cd"
                      title: "Launch post"
                      content: "We just launched!"
                      status: "scheduled"
                      scheduledFor: "2024-11-01T10:00:00Z"
                      platforms:
                        - platform: "twitter"
                          accountId:
                            _id: "64e1f0..."
                            platform: "twitter"
                            username: "@acme"
                            displayName: "Acme Corp"
                            isActive: true
                          status: "pending"
                publishedPost:
                  summary: Published post with platformPostUrl
                  value:
                    post:
                      _id: "65f1c0a9e2b5af0012ab34cd"
                      title: "Launch post"
                      content: "We just launched!"
                      status: "published"
                      publishedAt: "2024-11-01T10:00:05Z"
                      platforms:
                        - platform: "twitter"
                          accountId:
                            _id: "64e1f0a9e2b5af0012ab34de"
                            platform: "twitter"
                            username: "@acmecorp"
                            displayName: "Acme Corporation"
                            isActive: true
                          status: "published"
                          publishedAt: "2024-11-01T10:00:05Z"
                          platformPostId: "1852634789012345678"
                          platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678"
                        - platform: "linkedin"
                          accountId:
                            _id: "64e1f0a9e2b5af0012ab34ef"
                            platform: "linkedin"
                            username: "acme-corporation"
                            displayName: "Acme Corporation"
                            isActive: true
                          status: "published"
                          publishedAt: "2024-11-01T10:00:06Z"
                          platformPostId: "urn:li:share:7123456789012345678"
                          platformPostUrl: "https://www.linkedin.com/feed/update/urn:li:share:7123456789012345678"
                failedPost:
                  summary: Failed post with error details
                  value:
                    post:
                      _id: "65f1c0a9e2b5af0012ab34cd"
                      content: "This post failed to publish"
                      status: "failed"
                      platforms:
                        - platform: "instagram"
                          accountId:
                            _id: "64e1f0a9e2b5af0012ab34de"
                            platform: "instagram"
                            username: "acmecorp"
                            isActive: false
                          status: "failed"
                          errorMessage: "Instagram access token has expired. Please reconnect your account."
                          errorCategory: "auth_expired"
                          errorSource: "user"
                partialPost:
                  summary: Partial success (some platforms failed)
                  value:
                    post:
                      _id: "65f1c0a9e2b5af0012ab34cd"
                      content: "Launch announcement!"
                      status: "partial"
                      platforms:
                        - platform: "twitter"
                          accountId:
                            _id: "64e1f0a9e2b5af0012ab34de"
                            platform: "twitter"
                            username: "@acmecorp"
                            isActive: true
                          status: "published"
                          publishedAt: "2024-11-01T10:00:05Z"
                          platformPostId: "1852634789012345678"
                          platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678"
                        - platform: "threads"
                          accountId:
                            _id: "64e1f0a9e2b5af0012ab34ef"
                            platform: "threads"
                            username: "acmecorp"
                            isActive: true
                          status: "failed"
                          errorMessage: "Post text exceeds the 500 character limit for Threads."
                          errorCategory: "user_content"
                          errorSource: "user"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Forbidden
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      operationId: updatePost
      tags: [Posts]
      summary: Update post
      description: |
        Update an existing post. Only draft, scheduled, failed, and partial posts can be edited.
        Published, publishing, and cancelled posts cannot be modified.
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                content: { type: string }
                scheduledFor: { type: string, format: date-time }
                tiktokSettings:
                  $ref: '#/components/schemas/TikTokPlatformData'
                  description: Root-level TikTok settings applied to all TikTok platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence.
                facebookSettings:
                  $ref: '#/components/schemas/FacebookPlatformData'
                  description: Root-level Facebook settings applied to all Facebook platforms. Merged into each platform's platformSpecificData, with platform-specific settings taking precedence.
                recycling:
                  $ref: '#/components/schemas/RecyclingConfig'
              additionalProperties: true
            example:
              content: "Updated content for our launch post!"
              scheduledFor: "2024-11-02T14:00:00Z"
      responses:
        '200':
          description: Post updated
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostUpdateResponse'
              example:
                message: "Post updated successfully"
                post:
                  _id: "65f1c0a9e2b5af0012ab34cd"
                  content: "Updated content for our launch post!"
                  status: "scheduled"
                  scheduledFor: "2024-11-02T14:00:00Z"
        '207':
          description: Partial publish success
        '400':
          description: Invalid request
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Forbidden
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: deletePost
      tags: [Posts]
      summary: Delete post
      description: Delete a draft or scheduled post from Zernio. Published posts cannot be deleted; use the Unpublish endpoint instead. Upload quota is automatically refunded.
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostDeleteResponse'
              example:
                message: "Post deleted successfully"
        '400':
          description: Cannot delete published posts
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Forbidden
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/posts/bulk-upload:
    post:
      operationId: bulkUploadPosts
      tags: [Posts]
      summary: Bulk upload from CSV
      description: Create multiple posts by uploading a CSV file. Use dryRun=true to validate without creating posts.
      parameters:
        - name: dryRun
          in: query
          schema: { type: boolean, default: false }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              properties:
                file:
                  type: string
                  format: binary
      responses:
        '200':
          description: Bulk upload results
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  totalRows: { type: integer }
                  created: { type: integer }
                  failed: { type: integer }
                  errors:
                    type: array
                    items:
                      type: object
                      properties:
                        row: { type: integer }
                        error: { type: string }
                  posts:
                    type: array
                    items: { $ref: '#/components/schemas/Post' }
              example:
                success: true
                totalRows: 10
                created: 8
                failed: 2
                errors:
                  - row: 3
                    error: "Invalid date format"
                  - row: 7
                    error: "Account not found"
                posts:
                  - _id: "65f1c0a9e2b5af0012ab34cd"
                    content: "First bulk post"
                    status: "scheduled"
                    scheduledFor: "2024-11-01T10:00:00Z"
        '207':
          description: Partial success
        '400':
          description: Invalid CSV or validation errors
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429':
          description: |
            Rate limit exceeded. Possible causes: API rate limit (requests per minute) or account cooldown (one or more accounts for platforms specified in the CSV are temporarily rate-limited).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  details:
                    type: object
  /v1/posts/{postId}/retry:
    post:
      operationId: retryPost
      tags: [Posts]
      summary: Retry failed post
      description: Immediately retries publishing a failed post. Returns the updated post with its new status.
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Retry successful
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/PostRetryResponse'
              example:
                message: "Post published successfully"
                post:
                  _id: "65f1c0a9e2b5af0012ab34cd"
                  content: "Check out our new product!"
                  status: "published"
                  publishedAt: "2024-11-01T10:00:05Z"
                  platforms:
                    - platform: "twitter"
                      accountId:
                        _id: "64e1f0..."
                        platform: "twitter"
                        username: "@acme"
                        displayName: "Acme Corp"
                        isActive: true
                      status: "published"
                      platformPostId: "1234567890"
                      platformPostUrl: "https://twitter.com/acme/status/1234567890"
        '207':
          description: Partial success
        '400':
          description: Invalid state
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Forbidden
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Post is currently publishing
        '429':
          description: |
            Rate limit exceeded. Possible causes: API rate limit (requests per minute), velocity limit (15 posts/hour per account), or account cooldown (temporarily rate-limited due to repeated errors).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  details:
                    type: object
  /v1/posts/{postId}/unpublish:
    post:
      operationId: unpublishPost
      tags: [Posts]
      summary: Unpublish post
      description: |
        Deletes a published post from the specified platform. The post record in Zernio is kept but its status is updated to cancelled.
        Not supported on Instagram, TikTok, or Snapchat. Threaded posts delete all items. YouTube deletion is permanent.
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platform]
              properties:
                platform:
                  type: string
                  description: The platform to delete the post from
                  enum:
                    - threads
                    - facebook
                    - twitter
                    - linkedin
                    - youtube
                    - pinterest
                    - reddit
                    - bluesky
                    - googlebusiness
                    - telegram
            example:
              platform: "threads"
      responses:
        '200':
          description: Post deleted from platform
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
              example:
                success: true
                message: "Post deleted from threads successfully"
        '400':
          description: "Invalid request: platform not supported for deletion, post not on that platform, not published, no platform post ID, or no access token."
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Forbidden
        '404': { $ref: '#/components/responses/NotFound' }
        '500':
          description: Platform API deletion failed
  /v1/posts/{postId}/edit:
    post:
      operationId: editPost
      tags: [Posts]
      summary: Edit published post
      description: |
        Edit a published post on a social media platform. Currently only supported for X (Twitter).

        Requirements:
        - Connected X account must have an active X Premium subscription
        - Must be within 1 hour of original publish time
        - Maximum 5 edits per tweet (enforced by X)
        - Text-only edits (media changes are not supported)

        The post record in Zernio is updated with the new content and edit history.
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platform, content]
              properties:
                platform:
                  type: string
                  description: The platform to edit the post on. Currently only twitter is supported.
                  enum: [twitter]
                content:
                  type: string
                  description: The new tweet text content
            example:
              platform: "twitter"
              content: "Updated tweet text with corrected information"
      responses:
        '200':
          description: Post edited successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  id: { type: string, description: New tweet ID assigned by X after edit }
                  url: { type: string, format: uri, description: URL of the edited tweet }
                  message: { type: string }
              example:
                success: true
                id: "1234567890123456790"
                url: "https://twitter.com/i/web/status/1234567890123456790"
                message: "Tweet edited successfully"
        '400':
          description: "Invalid request: platform not supported, post not published, edit window expired, not Premium, or missing content."
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Forbidden
        '404': { $ref: '#/components/responses/NotFound' }
        '500':
          description: Platform API edit failed
  /v1/posts/{postId}/update-metadata:
    post:
      operationId: updatePostMetadata
      tags: [Posts]
      summary: Update post metadata
      description: |
        Updates metadata of a published video on the specified platform without re-uploading.
        Currently only supported for YouTube. At least one updatable field is required.

        Two modes:

        1. Post-based (video published through Zernio): pass the Zernio postId in the URL and platform in the body.
        2. Direct video ID (video uploaded outside Zernio, e.g. directly to YouTube): use _ as the postId,
           and pass videoId + accountId + platform in the body. The accountId is the Zernio social account ID
           for the connected YouTube channel.
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
          description: Zernio post ID, or "_" when using direct video ID mode
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platform]
              properties:
                platform:
                  type: string
                  description: The platform to update metadata on
                  enum:
                    - youtube
                videoId:
                  type: string
                  description: YouTube video ID (required for direct mode, ignored for post-based mode)
                accountId:
                  type: string
                  description: Zernio social account ID (required for direct mode, ignored for post-based mode)
                title:
                  type: string
                  maxLength: 100
                  description: New video title (max 100 characters for YouTube)
                description:
                  type: string
                  description: New video description
                tags:
                  type: array
                  items:
                    type: string
                    maxLength: 100
                  description: Array of keyword tags (max 500 characters combined for YouTube)
                categoryId:
                  type: string
                  description: YouTube video category ID
                privacyStatus:
                  type: string
                  enum: [public, private, unlisted]
                  description: Video privacy setting
                thumbnailUrl:
                  type: string
                  format: uri
                  description: "Public URL of a custom thumbnail image (JPEG, PNG, or GIF, max 2 MB, recommended 1280x720). Works on any video you own, including existing videos not published through Zernio. The channel must be verified (phone verification) to set custom thumbnails."
                madeForKids:
                  type: boolean
                  description: "COPPA compliance flag. Set true for child-directed content (restricts comments, notifications, ad targeting)."
                containsSyntheticMedia:
                  type: boolean
                  description: "AI-generated content disclosure. Set true if the video contains synthetic content that could be mistaken for real. YouTube may add a label."
                playlistId:
                  type: string
                  description: "YouTube playlist ID to add the video to (e.g. 'PLxxxxxxxxxxxxx'). Use GET /v1/accounts/{id}/youtube-playlists to list available playlists. Only playlists owned by the channel are supported."
            examples:
              post-based:
                summary: Update a video published through Zernio
                value:
                  platform: "youtube"
                  title: "Updated Video Title"
                  description: "New SEO-optimized description"
                  tags: ["seo", "marketing", "tutorial"]
              direct-video-id:
                summary: Update a video uploaded directly to YouTube
                value:
                  platform: "youtube"
                  videoId: "dQw4w9WgXcQ"
                  accountId: "68fb37418bbca9c10cbfef26"
                  title: "Updated Title with SEO Keywords"
                  tags: ["seo", "youtube", "optimization"]
              update-thumbnail:
                summary: Update thumbnail on an existing video
                value:
                  platform: "youtube"
                  videoId: "dQw4w9WgXcQ"
                  accountId: "68fb37418bbca9c10cbfef26"
                  thumbnailUrl: "https://example.com/my-thumbnail.jpg"
      responses:
        '200':
          description: Metadata updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
                  videoId: { type: string, description: Only present in direct video ID mode }
                  updatedFields:
                    type: array
                    items: { type: string }
              example:
                success: true
                message: "YouTube video metadata updated successfully"
                updatedFields: ["title", "description", "tags"]
        '400':
          description: "Invalid request: unsupported platform, post not published, missing fields, or validation error."
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Forbidden
        '404': { $ref: '#/components/responses/NotFound' }
        '500':
          description: Platform API update failed
  /v1/users:
    get:
      operationId: listUsers
      tags: [Users]
      summary: List users
      description: Returns all users in the workspace including roles and profile access. Also returns the currentUserId of the caller.
      responses:
        '200':
          description: Users
          content:
            application/json:
              schema:
                type: object
                properties:
                  currentUserId: { type: string }
                  users:
                    type: array
                    items:
                      type: object
                      properties:
                        _id: { type: string }
                        name: { type: string }
                        email: { type: string }
                        role: { type: string }
                        isRoot: { type: boolean }
                        profileAccess:
                          type: array
                          items: { type: string }
                        createdAt: { type: string, format: date-time }
              example:
                currentUserId: "6507a1b2c3d4e5f6a7b8c9d0"
                users:
                  - _id: "6507a1b2c3d4e5f6a7b8c9d0"
                    name: "John Doe"
                    email: "john@example.com"
                    role: "owner"
                    isRoot: true
                    profileAccess: ["all"]
                    createdAt: "2024-01-15T10:30:00Z"
                  - _id: "6507a1b2c3d4e5f6a7b8c9d1"
                    name: "Jane Smith"
                    email: "jane@example.com"
                    role: "member"
                    isRoot: false
                    profileAccess:
                      - "64f0a1b2c3d4e5f6a7b8c9d0"
                      - "64f0a1b2c3d4e5f6a7b8c9d1"
                    createdAt: "2024-03-20T14:45:00Z"
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/users/{userId}:
    get:
      operationId: getUser
      tags: [Users]
      summary: Get user
      description: Returns a single user's details by ID, including name, email, and role.
      parameters:
        - name: userId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: User
          content:
            application/json:
              schema:
                type: object
                properties:
                  user:
                    type: object
                    properties:
                      _id: { type: string }
                      name: { type: string }
                      email: { type: string }
                      role: { type: string }
                      isRoot: { type: boolean }
                      profileAccess:
                        type: array
                        items: { type: string }
              example:
                user:
                  _id: "6507a1b2c3d4e5f6a7b8c9d0"
                  name: "John Doe"
                  email: "john@example.com"
                  role: "owner"
                  isRoot: true
                  profileAccess: ["all"]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Forbidden
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/profiles:
    get:
      operationId: listProfiles
      tags: [Profiles]
      summary: List profiles
      description: Returns profiles sorted by creation date. Use includeOverLimit=true to include profiles that exceed the plan limit.
      parameters:
        - name: includeOverLimit
          in: query
          required: false
          schema:
            type: boolean
            default: false
          description: "When true, includes over-limit profiles (marked with isOverLimit: true)."
      responses:
        '200':
          description: Profiles
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProfilesListResponse'
              examples:
                example:
                  value:
                    profiles:
                      - _id: "64f0..."
                        name: "Personal Brand"
                        color: "#ffeda0"
                        isDefault: true
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createProfile
      tags: [Profiles]
      summary: Create profile
      description: Creates a new profile with a name, optional description, and color.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                description: { type: string }
                color: { type: string, example: '#ffeda0' }
            example:
              name: "Marketing Team"
              description: "Profile for marketing campaigns"
              color: "#4CAF50"
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProfileCreateResponse'
              example:
                message: "Profile created successfully"
                profile:
                  _id: "64f0a1b2c3d4e5f6a7b8c9d0"
                  userId: "6507a1b2c3d4e5f6a7b8c9d0"
                  name: "Marketing Team"
                  description: "Profile for marketing campaigns"
                  color: "#4CAF50"
                  isDefault: false
                  createdAt: "2024-11-01T10:00:00Z"
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Profile limit exceeded }
  /v1/profiles/{profileId}:
    get:
      operationId: getProfile
      tags: [Profiles]
      summary: Get profile
      description: Returns a single profile by ID, including its name, color, and default status.
      parameters:
        - name: profileId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Profile
          content:
            application/json:
              schema:
                type: object
                properties:
                  profile: { $ref: '#/components/schemas/Profile' }
              example:
                profile:
                  _id: "64f0a1b2c3d4e5f6a7b8c9d0"
                  userId: "6507a1b2c3d4e5f6a7b8c9d0"
                  name: "Marketing Team"
                  description: "Profile for marketing campaigns"
                  color: "#4CAF50"
                  isDefault: false
                  createdAt: "2024-11-01T10:00:00Z"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      operationId: updateProfile
      tags: [Profiles]
      summary: Update profile
      description: Updates a profile's name, description, color, or default status.
      parameters:
        - name: profileId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string }
                color: { type: string }
                isDefault: { type: boolean }
            example:
              name: "Marketing Team (Updated)"
              color: "#2196F3"
              isDefault: true
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  profile: { $ref: '#/components/schemas/Profile' }
              example:
                message: "Profile updated successfully"
                profile:
                  _id: "64f0a1b2c3d4e5f6a7b8c9d0"
                  userId: "6507a1b2c3d4e5f6a7b8c9d0"
                  name: "Marketing Team (Updated)"
                  description: "Profile for marketing campaigns"
                  color: "#2196F3"
                  isDefault: true
                  createdAt: "2024-11-01T10:00:00Z"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: deleteProfile
      tags: [Profiles]
      summary: Delete profile
      description: Permanently deletes a profile by ID.
      parameters:
        - name: profileId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
              example:
                message: "Profile deleted successfully"
        '400': { description: Has connected accounts }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Forbidden }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/accounts:
    get:
      operationId: listAccounts
      tags: [Accounts]
      summary: List accounts
      description: |
        Returns connected social accounts. Only includes accounts within the plan limit by default. Follower data requires analytics add-on.
        Supports optional server-side pagination via page/limit params. When omitted, returns all accounts (backward-compatible).
      parameters:
        - name: profileId
          in: query
          schema: { type: string }
          description: Filter accounts by profile ID
        - name: platform
          in: query
          schema: { type: string }
          description: Filter accounts by platform (e.g. "instagram", "twitter").
        - name: includeOverLimit
          in: query
          required: false
          schema:
            type: boolean
            default: false
          description: When true, includes accounts from over-limit profiles.
        - name: page
          in: query
          schema: { type: integer, minimum: 1 }
          description: Page number (1-based). When provided with limit, enables server-side pagination. Omit for all accounts.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100 }
          description: Page size. Required alongside page for pagination.
      responses:
        '200':
          description: Accounts (with optional pagination)
          content:
            application/json:
              schema:
                type: object
                properties:
                  accounts:
                    type: array
                    items: { $ref: '#/components/schemas/SocialAccount' }
                  hasAnalyticsAccess:
                    type: boolean
                    description: Whether user has analytics add-on access
                  pagination:
                    description: Only present when page/limit params are provided
                    $ref: '#/components/schemas/Pagination'
              examples:
                example:
                  value:
                    accounts:
                      - _id: "64e1..."
                        platform: "twitter"
                        profileId:
                          _id: "64f0..."
                          name: "My Brand"
                          slug: "my-brand"
                        username: "@acme"
                        displayName: "Acme"
                        profileUrl: "https://x.com/acme"
                        isActive: true
                    hasAnalyticsAccess: false
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/accounts/follower-stats:
    get:
      operationId: getFollowerStats
      tags: [Accounts, Analytics]
      summary: Get follower stats
      description: |
        Returns follower count history and growth metrics for connected social accounts.
        Requires analytics add-on subscription. Follower counts are refreshed once per day.
      parameters:
        - name: accountIds
          in: query
          schema: { type: string }
          description: Comma-separated list of account IDs (optional, defaults to all user's accounts)
        - name: profileId
          in: query
          schema: { type: string }
          description: Filter by profile ID
        - name: fromDate
          in: query
          schema: { type: string, format: date }
          description: Start date in YYYY-MM-DD format (defaults to 30 days ago)
        - name: toDate
          in: query
          schema: { type: string, format: date }
          description: End date in YYYY-MM-DD format (defaults to today)
        - name: granularity
          in: query
          schema: { type: string, enum: [daily, weekly, monthly], default: daily }
          description: Data aggregation level
      responses:
        '200':
          description: Follower stats
          content:
            application/json:
              schema:
                type: object
                properties:
                  accounts:
                    type: array
                    items:
                      $ref: '#/components/schemas/AccountWithFollowerStats'
                  stats:
                    type: object
                    additionalProperties:
                      type: array
                      items:
                        type: object
                        properties:
                          date: { type: string, format: date }
                          followers: { type: number }
                  dateRange:
                    type: object
                    properties:
                      from: { type: string, format: date-time }
                      to: { type: string, format: date-time }
                  granularity: { type: string }
              examples:
                example:
                  value:
                    accounts:
                      - _id: "64e1..."
                        platform: "twitter"
                        username: "@acme"
                        currentFollowers: 1250
                        growth: 50
                        growthPercentage: 4.17
                        dataPoints: 30
                    stats:
                      "64e1...":
                        - date: "2024-01-01"
                          followers: 1200
                        - date: "2024-01-02"
                          followers: 1250
                    dateRange:
                      from: "2024-01-01T00:00:00.000Z"
                      to: "2024-01-31T23:59:59.999Z"
                    granularity: "daily"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Analytics add-on required
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: Analytics add-on required }
                  message: { type: string, example: Follower stats tracking requires the Analytics add-on. Please upgrade to access this feature. }
                  requiresAddon: { type: boolean, example: true }
  /v1/accounts/{accountId}:
    put:
      operationId: updateAccount
      tags: [Accounts]
      summary: Update account
      description: Updates a connected social account's display name or username override.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                username: { type: string }
                displayName: { type: string }
            example:
              displayName: "Acme Corporation Official"
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  username: { type: string }
                  displayName: { type: string }
              example:
                message: "Account updated successfully"
                username: "@acmecorp"
                displayName: "Acme Corporation Official"
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: deleteAccount
      tags: [Accounts]
      summary: Disconnect account
      description: Disconnects and removes a connected social account.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Disconnected
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
              example:
                message: "Account disconnected successfully"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/accounts/health:
    get:
      operationId: getAllAccountsHealth
      tags: [Accounts]
      summary: Check accounts health
      description: Returns health status of all connected accounts including token validity, permissions, and issues needing attention.
      parameters:
        - name: profileId
          in: query
          description: Filter by profile ID
          schema: { type: string }
        - name: platform
          in: query
          description: Filter by platform
          schema:
            type: string
            enum: [facebook, instagram, linkedin, twitter, tiktok, youtube, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, discord]
        - name: status
          in: query
          description: Filter by health status
          schema:
            type: string
            enum: [healthy, warning, error]
      responses:
        '200':
          description: Account health summary
          content:
            application/json:
              schema:
                type: object
                properties:
                  summary:
                    type: object
                    properties:
                      total: { type: integer, description: Total number of accounts }
                      healthy: { type: integer, description: Number of healthy accounts }
                      warning: { type: integer, description: Number of accounts with warnings }
                      error: { type: integer, description: Number of accounts with errors }
                      needsReconnect: { type: integer, description: Number of accounts needing reconnection }
                  accounts:
                    type: array
                    items:
                      type: object
                      properties:
                        accountId: { type: string }
                        platform: { type: string }
                        username: { type: string }
                        displayName: { type: string }
                        profileId: { type: string }
                        status: { type: string, enum: [healthy, warning, error] }
                        canPost: { type: boolean }
                        canFetchAnalytics: { type: boolean }
                        tokenValid: { type: boolean }
                        tokenExpiresAt: { type: string, format: date-time }
                        needsReconnect: { type: boolean }
                        issues: { type: array, items: { type: string } }
              example:
                summary:
                  total: 5
                  healthy: 3
                  warning: 1
                  error: 1
                  needsReconnect: 1
                accounts:
                  - accountId: "abc123"
                    platform: "instagram"
                    username: "myaccount"
                    status: "healthy"
                    canPost: true
                    canFetchAnalytics: true
                    tokenValid: true
                    tokenExpiresAt: "2025-06-15T00:00:00Z"
                    needsReconnect: false
                    issues: []
                  - accountId: "def456"
                    platform: "twitter"
                    username: "mytwitter"
                    status: "error"
                    canPost: false
                    canFetchAnalytics: false
                    tokenValid: false
                    needsReconnect: true
                    issues: ["Token expired"]
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/accounts/{accountId}/health:
    get:
      operationId: getAccountHealth
      tags: [Accounts]
      summary: Check account health
      description: Returns detailed health info for a specific account including token status, permissions, and recommendations.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
          description: The account ID to check
      responses:
        '200':
          description: Account health details
          content:
            application/json:
              schema:
                type: object
                properties:
                  accountId: { type: string }
                  platform: { type: string }
                  username: { type: string }
                  displayName: { type: string }
                  status:
                    type: string
                    enum: [healthy, warning, error]
                    description: Overall health status
                  tokenStatus:
                    type: object
                    properties:
                      valid: { type: boolean, description: Whether the token is valid }
                      expiresAt: { type: string, format: date-time }
                      expiresIn: { type: string, description: Human-readable time until expiry }
                      needsRefresh: { type: boolean, description: Whether token expires within 24 hours }
                  permissions:
                    type: object
                    properties:
                      posting:
                        type: array
                        items:
                          type: object
                          properties:
                            scope: { type: string }
                            granted: { type: boolean }
                            required: { type: boolean }
                      analytics:
                        type: array
                        items:
                          type: object
                          properties:
                            scope: { type: string }
                            granted: { type: boolean }
                            required: { type: boolean }
                      optional:
                        type: array
                        items:
                          type: object
                          properties:
                            scope: { type: string }
                            granted: { type: boolean }
                            required: { type: boolean }
                      canPost: { type: boolean }
                      canFetchAnalytics: { type: boolean }
                      missingRequired: { type: array, items: { type: string } }
                  issues:
                    type: array
                    items: { type: string }
                    description: List of issues found
                  recommendations:
                    type: array
                    items: { type: string }
                    description: Actionable recommendations to fix issues
              example:
                accountId: "abc123"
                platform: "instagram"
                username: "myaccount"
                displayName: "My Account"
                status: "healthy"
                tokenStatus:
                  valid: true
                  expiresAt: "2025-06-15T00:00:00Z"
                  expiresIn: "180 days"
                  needsRefresh: false
                permissions:
                  posting:
                    - scope: "instagram_basic"
                      granted: true
                      required: true
                    - scope: "instagram_content_publish"
                      granted: true
                      required: true
                  analytics:
                    - scope: "instagram_manage_insights"
                      granted: true
                      required: false
                  optional: []
                  canPost: true
                  canFetchAnalytics: true
                  missingRequired: []
                issues: []
                recommendations: []
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/accounts/{accountId}/tiktok/creator-info:
    get:
      operationId: getTikTokCreatorInfo
      tags: [Accounts]
      summary: Get TikTok creator info
      description: Returns TikTok creator details, available privacy levels, posting limits, and commercial content options for a specific TikTok account. Only works with TikTok accounts.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
          description: The TikTok account ID
        - name: mediaType
          in: query
          required: false
          schema:
            type: string
            enum: [video, photo]
            default: video
          description: The media type to get creator info for (affects available interaction settings)
      responses:
        '200':
          description: TikTok creator info and posting options
          content:
            application/json:
              schema:
                type: object
                properties:
                  creator:
                    type: object
                    properties:
                      nickname: { type: string, description: Creator display name }
                      avatarUrl: { type: string, description: Creator avatar URL }
                      isVerified: { type: boolean, description: Whether the creator is verified }
                      canPostMore: { type: boolean, description: Whether the creator can publish more posts right now }
                  privacyLevels:
                    type: array
                    description: Available privacy level options for this creator
                    items:
                      type: object
                      properties:
                        value: { type: string, description: "Privacy level value to use when creating posts (e.g. PUBLIC_TO_EVERYONE, MUTUAL_FOLLOW_FRIENDS, FOLLOWER_OF_CREATOR, SELF_ONLY)" }
                        label: { type: string, description: Human-readable label }
                  postingLimits:
                    type: object
                    properties:
                      maxVideoDurationSec: { type: integer, description: Maximum video duration in seconds }
                      interactionSettings:
                        type: object
                        description: Available interaction toggles (comment, duet, stitch) and their defaults
                  commercialContentTypes:
                    type: array
                    description: Available commercial content disclosure options
                    items:
                      type: object
                      properties:
                        value: { type: string }
                        label: { type: string }
                        requires:
                          type: array
                          items: { type: string }
              example:
                creator:
                  nickname: "myaccount"
                  avatarUrl: "https://example.com/avatar.jpg"
                  isVerified: false
                  canPostMore: true
                privacyLevels:
                  - value: "PUBLIC_TO_EVERYONE"
                    label: "Public To Everyone"
                  - value: "MUTUAL_FOLLOW_FRIENDS"
                    label: "Mutual Follow Friends"
                  - value: "SELF_ONLY"
                    label: "Self Only"
                postingLimits:
                  maxVideoDurationSec: 600
                  interactionSettings:
                    comment: true
                    duet: true
                    stitch: true
                commercialContentTypes:
                  - value: "none"
                    label: "No Commercial Content"
                  - value: "brand_organic"
                    label: "Your Brand"
                    requires: ["is_brand_organic_post"]
        '400':
          description: Account is not a TikTok account
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
              example:
                error: "This endpoint is only available for TikTok accounts"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '429':
          description: Creator has reached TikTok daily posting limit
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
              example:
                error: "TikTok creator has reached the daily posting limit. Please try again later."
  /v1/api-keys:
    get:
      operationId: listApiKeys
      tags: [API Keys]
      summary: List keys
      description: Returns all API keys for the authenticated user. Keys are returned with a preview only, not the full key value.
      responses:
        '200':
          description: API keys
          content:
            application/json:
              schema:
                type: object
                properties:
                  apiKeys:
                    type: array
                    items: { $ref: '#/components/schemas/ApiKey' }
              example:
                apiKeys:
                  - id: "6507a1b2c3d4e5f6a7b8c9d0"
                    name: "Production API Key"
                    keyPreview: "sk_12345678...abcdef01"
                    expiresAt: "2025-12-31T23:59:59Z"
                    createdAt: "2024-01-15T10:30:00Z"
                    scope: "full"
                    profileIds: []
                    permission: "read-write"
                  - id: "6507a1b2c3d4e5f6a7b8c9d1"
                    name: "Analytics Read-Only"
                    keyPreview: "sk_87654321...12345678"
                    expiresAt: null
                    createdAt: "2024-03-20T14:45:00Z"
                    scope: "profiles"
                    profileIds:
                      - _id: "6507a1b2c3d4e5f6a7b8c9d0"
                        name: "Main Brand"
                        color: "#ffeda0"
                    permission: "read"
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createApiKey
      tags: [API Keys]
      summary: Create key
      description: Creates a new API key with an optional expiry. The full key value is only returned once in the response.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string }
                expiresIn:
                  type: integer
                  description: Days until expiry
                scope:
                  type: string
                  enum: [full, profiles]
                  description: "'full' grants access to all profiles (default), 'profiles' restricts to specific profiles"
                  default: full
                profileIds:
                  type: array
                  items: { type: string }
                  description: Profile IDs this key can access. Required when scope is 'profiles'.
                permission:
                  type: string
                  enum: [read-write, read]
                  description: "'read-write' allows all operations (default), 'read' restricts to GET requests only"
                  default: read-write
            example:
              name: "Analytics Read-Only Key"
              scope: "profiles"
              profileIds: ["6507a1b2c3d4e5f6a7b8c9d0"]
              permission: "read"
      responses:
        '201':
          description: Created
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  apiKey: { $ref: '#/components/schemas/ApiKey' }
              example:
                message: "API key created successfully"
                apiKey:
                  id: "6507a1b2c3d4e5f6a7b8c9d0"
                  name: "Analytics Read-Only Key"
                  keyPreview: "sk_12345678...90abcdef"
                  key: "sk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
                  expiresAt: null
                  createdAt: "2024-01-15T10:30:00Z"
                  scope: "profiles"
                  profileIds:
                    - _id: "6507a1b2c3d4e5f6a7b8c9d0"
                      name: "Main Brand"
                      color: "#ffeda0"
                  permission: "read"
        '400': { description: Invalid request (missing name, invalid scope/permission, or missing profileIds when scope is 'profiles') }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/api-keys/{keyId}:
    delete:
      operationId: deleteApiKey
      tags: [API Keys]
      summary: Delete key
      description: Permanently revokes and deletes an API key.
      parameters:
        - name: keyId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
              example:
                message: "API key deleted successfully"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/invite/tokens:
    post:
      operationId: createInviteToken
      tags: [Invites]
      summary: Create invite token
      description: |
        Generate a secure invite link to grant team members access to your profiles.
        Invites expire after 7 days and are single-use.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scope]
              properties:
                scope:
                  type: string
                  enum: [all, profiles]
                  description: "'all' grants access to all profiles, 'profiles' restricts to specific profiles"
                profileIds:
                  type: array
                  items: { type: string }
                  description: Required if scope is 'profiles'. Array of profile IDs to grant access to.
            example:
              scope: "profiles"
              profileIds:
                - "64f0a1b2c3d4e5f6a7b8c9d0"
                - "64f0a1b2c3d4e5f6a7b8c9d1"
      responses:
        '201':
          description: Invite token created
          content:
            application/json:
              schema:
                type: object
                properties:
                  token: { type: string }
                  scope: { type: string }
                  invitedProfileIds:
                    type: array
                    items: { type: string }
                  expiresAt: { type: string, format: date-time }
                  inviteUrl: { type: string, format: uri }
              example:
                token: "inv_abc123def456ghi789"
                scope: "profiles"
                invitedProfileIds:
                  - "64f0a1b2c3d4e5f6a7b8c9d0"
                  - "64f0a1b2c3d4e5f6a7b8c9d1"
                expiresAt: "2024-11-08T10:30:00Z"
                inviteUrl: "https://zernio.com/invite/inv_abc123def456ghi789"
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: One or more profiles not found or not owned }

  /v1/connect/{platform}:
    get:
      operationId: getConnectUrl
      tags: [Connect]
      summary: Get OAuth connect URL
      description: |
        Initiate an OAuth connection flow. Returns an authUrl to redirect the user to.
        Standard flow: Zernio hosts the selection UI, then redirects to your redirect_url. Headless mode (headless=true): user is redirected to your redirect_url with OAuth data for custom UI. Use the platform-specific selection endpoints to complete.
      parameters:
        - name: platform
          in: path
          required: true
          schema:
            type: string
            enum: [facebook, instagram, linkedin, twitter, tiktok, youtube, threads, reddit, pinterest, bluesky, googlebusiness, telegram, snapchat, discord]
          description: Social media platform to connect
        - name: profileId
          in: query
          required: true
          schema: { type: string }
          description: Your Zernio profile ID (get from /v1/profiles)
        - name: redirect_url
          in: query
          schema: { type: string, format: uri }
          description: Your custom redirect URL after connection completes. Standard mode appends ?connected={platform}&profileId=X&accountId=Y&username=Z. Headless mode appends OAuth data params for platforms requiring selection (e.g. LinkedIn orgs, Facebook pages). If no selection is needed, the account is created directly and the redirect includes accountId.
        - name: headless
          in: query
          schema: { type: boolean, default: false }
          description: When true, the user is redirected to your redirect_url with raw OAuth data (code, state) instead of Zernio's default account selection UI. Use this to build a custom connect experience.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: OAuth authorization URL to redirect user to
          content:
            application/json:
              schema:
                type: object
                properties:
                  authUrl: 
                    type: string
                    format: uri
                    description: URL to redirect your user to for OAuth authorization
                  state: 
                    type: string
                    description: State parameter for security (handled automatically)
              example:
                authUrl: "https://www.facebook.com/v21.0/dialog/oauth?client_id=..."
                state: "user123-profile456-1234567890-https://yourdomain.com/callback"
        '400':
          description: "Missing/invalid parameters (e.g., invalid profileId format)"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: "No access to profile, or BYOK required for AppSumo Twitter"
        '404':
          description: Profile not found
    post:
      operationId: handleOAuthCallback
      tags: [Connect]
      summary: Complete OAuth callback
      description: Exchange the OAuth authorization code for tokens and connect the account to the specified profile.
      parameters:
        - name: platform
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code, state, profileId]
              properties:
                code: { type: string }
                state: { type: string }
                profileId: { type: string }
      responses:
        '200': { description: Account connected }
        '400': { description: Invalid params }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: BYOK required for AppSumo Twitter }
        '500': { description: Failed to connect account }

  /v1/connect/{platform}/ads:
    get:
      operationId: connectAds
      tags: [Connect]
      summary: Connect ads for a platform
      description: |
        Unified ads connection endpoint. Creates a dedicated ads SocialAccount for the specified platform.

        Same-token platforms (facebook, instagram, linkedin, pinterest): Creates an ads SocialAccount (metaads, linkedinads, pinterestads) with a copied OAuth token from the parent posting account. If the ads account already exists, returns alreadyConnected: true. No extra OAuth needed.

        Separate-token platforms (tiktok, twitter): Starts the platform-specific marketing API OAuth flow and creates an ads SocialAccount (tiktokads, xads) with its own token. Requires an existing posting account (accountId param). If the ads account already exists, returns alreadyConnected: true.

        Standalone platforms (googleads): Starts the Google Ads OAuth flow and creates a standalone ads SocialAccount (googleads) with no parent. If the account already exists, returns alreadyConnected: true.

        Ads accounts appear as regular SocialAccount documents with ads platform values (e.g., metaads, tiktokads) in GET /v1/accounts.
      parameters:
        - name: platform
          in: path
          required: true
          schema:
            type: string
            enum: [facebook, instagram, linkedin, tiktok, twitter, pinterest, googleads]
          description: Platform to connect ads for. Only platforms with ads support are accepted.
        - name: profileId
          in: query
          required: true
          schema: { type: string }
          description: Your Zernio profile ID
        - name: accountId
          in: query
          schema: { type: string }
          description: Existing SocialAccount ID. Required for separate-token platforms (tiktok, twitter). Ignored for same-token and standalone platforms.
        - name: redirect_url
          in: query
          schema: { type: string, format: uri }
          description: Custom redirect URL after OAuth completes (same-token platforms only)
        - name: headless
          in: query
          schema: { type: boolean, default: false }
          description: Enable headless mode (same-token platforms only)
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Either an OAuth URL to redirect to, or confirmation that ads are already connected
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    description: Ads already connected (no OAuth needed)
                    properties:
                      alreadyConnected: { type: boolean, example: true }
                      accountId: { type: string }
                      platform: { type: string }
                      username: { type: string }
                      displayName: { type: string }
                  - type: object
                    description: OAuth URL to redirect user to
                    properties:
                      authUrl: { type: string, format: uri }
                      state: { type: string }
              examples:
                alreadyConnected:
                  summary: Same-token platform (Meta) with existing account
                  value:
                    alreadyConnected: true
                    accountId: "664a1b2c3d4e5f6789012345"
                    platform: "instagram"
                    username: "@mybrand"
                    displayName: "My Brand"
                oauthRequired:
                  summary: Separate-token platform (TikTok) needing ads OAuth
                  value:
                    authUrl: "https://business-api.tiktok.com/portal/auth?app_id=..."
                    state: "user123-profile456-account789-1234567890"
        '400':
          description: "Platform doesn't support ads, or missing accountId for separate-token platform"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: "Ads add-on required, or no access to profile"
        '404':
          description: "Profile or posting account not found"

  /v1/connect/facebook/select-page:
    get:
      operationId: listFacebookPages
      tags: [Connect]
      summary: List Facebook pages
      description: Returns the list of Facebook Pages the user can manage after OAuth. Extract tempToken and userProfile from the OAuth redirect params and pass them here. Use the X-Connect-Token header if connecting via API key.
      parameters:
        - name: profileId
          in: query
          required: true
          schema: { type: string }
          description: Profile ID from your connection flow
        - name: tempToken
          in: query
          required: true
          schema: { type: string }
          description: Temporary Facebook access token from the OAuth callback redirect
      security:
        - bearerAuth: []
        - connectToken: []
      responses:
        '200':
          description: List of Facebook Pages available for connection
          content:
            application/json:
              schema:
                type: object
                properties:
                  pages:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: Facebook Page ID }
                        name: { type: string, description: Page name }
                        username: { type: string, description: Page username/handle (may be null) }
                        access_token: { type: string, description: Page-specific access token }
                        category: { type: string, description: Page category }
                        tasks: { type: array, items: { type: string }, description: User permissions for this page }
              example:
                pages:
                  - id: "123456789"
                    name: "My Brand Page"
                    username: "mybrand"
                    access_token: "EAAxxxxx..."
                    category: "Brand"
                    tasks: ["MANAGE", "CREATE_CONTENT"]
        '400': { description: Missing required parameters (profileId or tempToken) }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': 
          description: Failed to fetch pages (e.g., invalid token, insufficient permissions)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
    post:
      operationId: selectFacebookPage
      tags: [Connect]
      summary: Select Facebook page
      description: Complete the headless flow by saving the user's selected Facebook page. Pass the userProfile from the OAuth redirect and use X-Connect-Token if connecting via API key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, pageId, tempToken, userProfile]
              properties:
                profileId: 
                  type: string
                  description: Profile ID from your connection flow
                pageId: 
                  type: string
                  description: The Facebook Page ID selected by the user
                tempToken: 
                  type: string
                  description: Temporary Facebook access token from OAuth
                userProfile: 
                  type: object
                  description: Decoded user profile object from the OAuth callback
                  properties:
                    id: { type: string }
                    name: { type: string }
                    profilePicture: { type: string }
                redirect_url: 
                  type: string
                  format: uri
                  description: Optional custom redirect URL to return to after selection
            example:
              profileId: "507f1f77bcf86cd799439011"
              pageId: "123456789"
              tempToken: "EAAxxxxx..."
              userProfile:
                id: "987654321"
                name: "John Doe"
                profilePicture: "https://..."
              redirect_url: "https://yourdomain.com/integrations/callback"
      security:
        - bearerAuth: []
        - connectToken: []
      responses:
        '200': 
          description: Facebook Page connected successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  redirect_url: 
                    type: string
                    description: Redirect URL if custom redirect_url was provided
                  account:
                    type: object
                    properties:
                      accountId:
                        type: string
                        description: ID of the created SocialAccount
                      platform: { type: string, enum: [facebook] }
                      username: { type: string }
                      displayName: { type: string }
                      profilePicture: { type: string }
                      isActive: { type: boolean }
                      selectedPageName: { type: string }
              example:
                message: "Facebook page connected successfully"
                redirect_url: "https://yourdomain.com/integrations/callback?connected=facebook&profileId=507f1f77bcf86cd799439011&username=My+Brand+Page"
                account:
                  accountId: "64e1f0a9e2b5af0012ab34cd"
                  platform: "facebook"
                  username: "mybrand"
                  displayName: "My Brand Page"
                  profilePicture: "https://..."
                  isActive: true
                  selectedPageName: "My Brand Page"
        '400':
          description: "Missing required fields (profileId, pageId, tempToken, or userProfile)"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: User does not have access to the specified profile
        '404':
          description: Selected page not found in available pages
        '500':
          description: Failed to save Facebook connection

  /v1/connect/googlebusiness/locations:
    get:
      operationId: listGoogleBusinessLocations
      tags: [Connect]
      summary: List GBP locations
      description: >
        For headless flows. Returns the list of GBP locations the user can manage.
        Use pendingDataToken (from the OAuth callback redirect) to list locations
        without consuming the token, so it remains available for select-location.
        Use X-Connect-Token header if connecting via API key.
      parameters:
        - name: profileId
          in: query
          required: false
          schema: { type: string }
          description: Profile ID from your connection flow. Required for auth validation when provided.
        - name: pendingDataToken
          in: query
          required: false
          schema: { type: string }
          description: Token from the OAuth callback redirect. Preferred over tempToken because it preserves server-side token storage. One of pendingDataToken or tempToken is required.
        - name: tempToken
          in: query
          required: false
          schema: { type: string }
          description: Legacy. Direct Google access token. Use pendingDataToken instead when available.
      security:
        - bearerAuth: []
        - connectToken: []
      responses:
        '200':
          description: List of Google Business locations available for connection
          content:
            application/json:
              schema:
                type: object
                properties:
                  locations:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: Location ID }
                        name: { type: string, description: Business name }
                        accountId: { type: string, description: Google Business Account ID }
                        accountName: { type: string, description: Account name }
                        address: { type: string, description: Business address }
                        category: { type: string, description: Business category }
              example:
                locations:
                  - id: "9281089117903930794"
                    name: "My Coffee Shop"
                    accountId: "accounts/113303573364907650416"
                    accountName: "My Business Account"
                    address: "123 Main St, City, Country"
                    category: "Coffee shop"
        '400': { description: Missing required parameters (profileId or tempToken) }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': 
          description: Failed to fetch locations (e.g., invalid token, insufficient permissions)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }

  /v1/connect/googlebusiness/select-location:
    post:
      operationId: selectGoogleBusinessLocation
      tags: [Connect]
      summary: Select GBP location
      description: >
        Complete the headless GBP flow by saving the user's selected location.
        The pendingDataToken is returned in your redirect URL after OAuth completes
        (step=select_location). Tokens and profile data are stored server-side,
        so only the pendingDataToken is needed here. Use X-Connect-Token header
        if connecting via API key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, locationId, pendingDataToken]
              properties:
                profileId:
                  type: string
                  description: Profile ID from your connection flow
                locationId:
                  type: string
                  description: The Google Business location ID selected by the user
                pendingDataToken:
                  type: string
                  description: Token from the OAuth callback redirect (pendingDataToken query param). Tokens and profile data are retrieved server-side from this token.
                redirect_url:
                  type: string
                  format: uri
                  description: Optional custom redirect URL to return to after selection
            example:
              profileId: "507f1f77bcf86cd799439011"
              locationId: "9281089117903930794"
              pendingDataToken: "a1b2c3d4e5f6..."
              redirect_url: "https://yourdomain.com/integrations/callback"
      security:
        - bearerAuth: []
        - connectToken: []
      responses:
        '200': 
          description: Google Business location connected successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  redirect_url: 
                    type: string
                    description: Redirect URL if custom redirect_url was provided
                  account:
                    type: object
                    properties:
                      accountId:
                        type: string
                        description: ID of the created SocialAccount
                      platform: { type: string, enum: [googlebusiness] }
                      username: { type: string }
                      displayName: { type: string }
                      isActive: { type: boolean }
                      selectedLocationName: { type: string }
                      selectedLocationId: { type: string }
              example:
                message: "Google Business location connected successfully"
                redirect_url: "https://yourdomain.com/integrations/callback?connected=googlebusiness&profileId=507f1f77bcf86cd799439011&username=My+Coffee+Shop"
                account:
                  accountId: "64e1f0a9e2b5af0012ab34cd"
                  platform: "googlebusiness"
                  username: "My Coffee Shop"
                  displayName: "My Coffee Shop"
                  isActive: true
                  selectedLocationName: "My Coffee Shop"
                  selectedLocationId: "9281089117903930794"
        '400':
          description: "Missing required fields (profileId, locationId, or tempToken)"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: User does not have access to the specified profile
        '404':
          description: Selected location not found in available locations
        '500':
          description: Failed to save Google Business connection

  /v1/accounts/{accountId}/gmb-reviews:
    get:
      operationId: getGoogleBusinessReviews
      tags: [GMB Reviews]
      summary: Get reviews
      description: Returns reviews for a GBP account including ratings, comments, and owner replies. Use nextPageToken for pagination.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
          description: The Zernio account ID (from /v1/accounts)
        - name: locationId
          in: query
          schema: { type: string }
          description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
        - name: pageSize
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 50 }
          description: Number of reviews to fetch per page (max 50)
        - name: pageToken
          in: query
          schema: { type: string }
          description: Pagination token from previous response
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Reviews fetched successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  accountId: { type: string }
                  locationId: { type: string }
                  reviews:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: Review ID }
                        name: { type: string, description: Full resource name }
                        reviewer:
                          type: object
                          properties:
                            displayName: { type: string }
                            profilePhotoUrl: { type: string, nullable: true }
                            isAnonymous: { type: boolean }
                        rating: { type: integer, minimum: 1, maximum: 5, description: Numeric star rating }
                        starRating: { type: string, enum: [ONE, TWO, THREE, FOUR, FIVE], description: Google's string rating }
                        comment: { type: string, description: Review text }
                        createTime: { type: string, format: date-time }
                        updateTime: { type: string, format: date-time }
                        reviewReply:
                          type: object
                          nullable: true
                          properties:
                            comment: { type: string, description: Business owner reply }
                            updateTime: { type: string, format: date-time }
                  averageRating: { type: number, description: Overall average rating }
                  totalReviewCount: { type: integer, description: Total number of reviews }
                  nextPageToken: { type: string, nullable: true, description: Token for next page }
              example:
                success: true
                accountId: "64e1f0a9e2b5af0012ab34cd"
                locationId: "9281089117903930794"
                reviews:
                  - id: "AIe9_BGx1234567890"
                    name: "accounts/123456789/locations/9281089117903930794/reviews/AIe9_BGx1234567890"
                    reviewer:
                      displayName: "John Smith"
                      profilePhotoUrl: "https://lh3.googleusercontent.com/a/..."
                      isAnonymous: false
                    rating: 5
                    starRating: "FIVE"
                    comment: "Great service and friendly staff! Highly recommend."
                    createTime: "2024-01-15T10:30:00Z"
                    updateTime: "2024-01-15T10:30:00Z"
                    reviewReply:
                      comment: "Thank you for your kind words! We appreciate your support."
                      updateTime: "2024-01-16T08:00:00Z"
                  - id: "AIe9_BGx0987654321"
                    name: "accounts/123456789/locations/9281089117903930794/reviews/AIe9_BGx0987654321"
                    reviewer:
                      displayName: "Anonymous"
                      profilePhotoUrl: null
                      isAnonymous: true
                    rating: 4
                    starRating: "FOUR"
                    comment: "Good experience overall."
                    createTime: "2024-01-10T14:20:00Z"
                    updateTime: "2024-01-10T14:20:00Z"
                    reviewReply: null
                averageRating: 4.5
                totalReviewCount: 125
                nextPageToken: "CiAKHAoUMTIzNDU2Nzg5"
        '400':
          description: Invalid request - not a Google Business account or missing location
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
              example:
                error: "This endpoint is only available for Google Business Profile accounts"
        '401':
          description: Unauthorized or token invalid
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
              example:
                error: "Access token invalid. Please reconnect your Google Business Profile account."
                code: "token_invalid"
        '403':
          description: Permission denied for this location
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
              example:
                error: "You do not have permission to access reviews for this location."
        '404': { $ref: '#/components/responses/NotFound' }
        '500':
          description: Failed to fetch reviews
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /v1/accounts/{accountId}/gmb-food-menus:
    get:
      operationId: getGoogleBusinessFoodMenus
      tags: [GMB Food Menus]
      summary: Get food menus
      description: Returns food menus for a GBP location including sections, items, pricing, and dietary info. Only for locations with food menu support.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
          description: The Zernio account ID (from /v1/accounts)
        - name: locationId
          in: query
          schema: { type: string }
          description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Food menus fetched successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  accountId: { type: string }
                  locationId: { type: string }
                  name: { type: string, description: Resource name of the food menus }
                  menus:
                    type: array
                    items:
                      $ref: '#/components/schemas/FoodMenu'
              example:
                success: true
                accountId: "64e1f0a9e2b5af0012ab34cd"
                locationId: "9281089117903930794"
                name: "accounts/123456789/locations/9281089117903930794/foodMenus"
                menus:
                  - labels:
                      - displayName: "Lunch Menu"
                        description: "Available 11am-3pm"
                        languageCode: "en"
                    sections:
                      - labels:
                          - displayName: "Appetizers"
                        items:
                          - labels:
                              - displayName: "Caesar Salad"
                                description: "Romaine, parmesan, croutons"
                            attributes:
                              price:
                                currencyCode: "USD"
                                units: "12"
                              dietaryRestriction: ["VEGETARIAN"]
        '400':
          description: Invalid request - not a Google Business account or missing location
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
              example:
                error: "This endpoint is only available for Google Business Profile accounts"
        '401':
          description: Unauthorized or token invalid
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
              example:
                error: "Access token invalid. Please reconnect your Google Business Profile account."
                code: "token_invalid"
        '403':
          description: Permission denied for this location
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
              example:
                error: "You do not have permission to access food menus for this location."
        '404': { $ref: '#/components/responses/NotFound' }
        '500':
          description: Failed to fetch food menus
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
    put:
      operationId: updateGoogleBusinessFoodMenus
      tags: [GMB Food Menus]
      summary: Update food menus
      description: Updates food menus for a GBP location. Send the full menus array. Use updateMask for partial updates.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
          description: The Zernio account ID (from /v1/accounts)
        - name: locationId
          in: query
          schema: { type: string }
          description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [menus]
              properties:
                menus:
                  type: array
                  items:
                    $ref: '#/components/schemas/FoodMenu'
                  description: Array of food menus to set
                updateMask:
                  type: string
                  description: Field mask for partial updates (e.g. "menus")
            example:
              menus:
                - labels:
                    - displayName: "Dinner Menu"
                      languageCode: "en"
                  sections:
                    - labels:
                        - displayName: "Mains"
                      items:
                        - labels:
                            - displayName: "Grilled Salmon"
                              description: "With seasonal vegetables"
                          attributes:
                            price:
                              currencyCode: "USD"
                              units: "24"
                            allergen: ["FISH"]
              updateMask: "menus"
      responses:
        '200':
          description: Food menus updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  accountId: { type: string }
                  locationId: { type: string }
                  name: { type: string }
                  menus:
                    type: array
                    items:
                      $ref: '#/components/schemas/FoodMenu'
        '400':
          description: Invalid request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
              example:
                error: "Request body must include a \"menus\" array"
        '401':
          description: Unauthorized or token expired
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '403':
          description: Permission denied for this location
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500':
          description: Failed to update food menus
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /v1/accounts/{accountId}/gmb-location-details:
    get:
      operationId: getGoogleBusinessLocationDetails
      tags: [GMB Location Details]
      summary: Get location details
      description: Returns detailed GBP location info (hours, description, phone, website, categories, services). Use readMask to request specific fields.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
          description: The Zernio account ID (from /v1/accounts)
        - name: locationId
          in: query
          schema: { type: string }
          description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
        - name: readMask
          in: query
          required: false
          schema: { type: string }
          description: |
            Comma-separated fields to return. Available: name, title, phoneNumbers, categories, storefrontAddress, websiteUri, regularHours, specialHours, serviceArea, serviceItems, profile, openInfo, metadata, moreHours.
            `title` and `metadata` are always included in the response so the `location` summary block can be populated, even if you omit them here.
            Note: `location` is a derived response field, not a Google readMask value, passing it returns 400.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Location details fetched successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  accountId: { type: string }
                  locationId: { type: string }
                  location:
                    type: object
                    nullable: true
                    description: |
                      Compact public-facing summary derived from Google's `metadata`. Useful
                      for surfacing the "leave a review" URL (e.g. behind a QR code) without
                      parsing the raw block. Always populated regardless of readMask.
                      For unverified or new locations Google omits placeId/reviewUrl/mapsUri,
                      so those return as null and `isVerified` is false.
                    properties:
                      name: { type: string, nullable: true, description: Business name as set in GBP }
                      placeId: { type: string, nullable: true, description: Google Maps Place ID for this location }
                      reviewUrl: { type: string, nullable: true, description: Public "write a review" URL Google generates for this place }
                      mapsUri: { type: string, nullable: true, description: Public Google Maps URL for this location }
                      isVerified: { type: boolean, description: True when the location has Voice of Merchant (verified + live on Google) }
                  title: { type: string, description: Business name }
                  regularHours:
                    type: object
                    properties:
                      periods:
                        type: array
                        items:
                          type: object
                          properties:
                            openDay: { type: string, enum: [MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY] }
                            openTime: { type: string, description: "Opening time in HH:MM format" }
                            closeDay: { type: string }
                            closeTime: { type: string }
                  specialHours:
                    type: object
                    properties:
                      specialHourPeriods:
                        type: array
                        items:
                          type: object
                          properties:
                            startDate: { type: object, properties: { year: { type: integer }, month: { type: integer }, day: { type: integer } } }
                            endDate: { type: object, properties: { year: { type: integer }, month: { type: integer }, day: { type: integer } } }
                            openTime: { type: string }
                            closeTime: { type: string }
                            closed: { type: boolean }
                  profile:
                    type: object
                    properties:
                      description: { type: string, description: Business description }
                  websiteUri: { type: string }
                  phoneNumbers:
                    type: object
                    properties:
                      primaryPhone: { type: string }
                      additionalPhones: { type: array, items: { type: string } }
                  categories:
                    type: object
                    description: "Business categories (returned when readMask includes 'categories')"
                    properties:
                      primaryCategory:
                        type: object
                        properties:
                          name: { type: string, description: "Category resource name" }
                          displayName: { type: string, description: "Human-readable category name" }
                      additionalCategories:
                        type: array
                        items:
                          type: object
                          properties:
                            name: { type: string }
                            displayName: { type: string }
                  serviceItems:
                    type: array
                    description: "Services offered (returned when readMask includes 'serviceItems')"
                    items:
                      type: object
                      properties:
                        structuredServiceItem:
                          type: object
                          properties:
                            serviceTypeId: { type: string }
                            description: { type: string }
                        freeFormServiceItem:
                          type: object
                          properties:
                            category: { type: string }
                            label:
                              type: object
                              properties:
                                displayName: { type: string }
                                languageCode: { type: string }
                        price:
                          type: object
                          properties:
                            currencyCode: { type: string }
                            units: { type: string }
                            nanos: { type: integer }
              example:
                success: true
                accountId: "64e1f0a9e2b5af0012ab34cd"
                locationId: "9281089117903930794"
                location:
                  name: "Joe's Pizza"
                  placeId: "ChIJExampleJoesPizzaPlaceId"
                  reviewUrl: "https://search.google.com/local/writereview?placeid=ChIJExampleJoesPizzaPlaceId"
                  mapsUri: "https://maps.google.com/maps?cid=1234567890123456789"
                  isVerified: true
                title: "Joe's Pizza"
                regularHours:
                  periods:
                    - openDay: "MONDAY"
                      openTime: "11:00"
                      closeDay: "MONDAY"
                      closeTime: "22:00"
                    - openDay: "TUESDAY"
                      openTime: "11:00"
                      closeDay: "TUESDAY"
                      closeTime: "22:00"
                specialHours:
                  specialHourPeriods:
                    - startDate: { year: 2026, month: 12, day: 25 }
                      closed: true
                profile:
                  description: "Authentic New York style pizza since 1985"
                websiteUri: "https://joespizza.com"
                categories:
                  primaryCategory:
                    name: "categories/gcid:pizza_restaurant"
                    displayName: "Pizza restaurant"
                  additionalCategories:
                    - name: "categories/gcid:italian_restaurant"
                      displayName: "Italian restaurant"
        '400':
          description: |
            Invalid request. Most commonly raised when the readMask query
            includes a value that is not a valid Google Business Information
            field (e.g. `location`, which is a response-only derived field).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
              example:
                error: "Request contains an invalid argument."
                code: "gbp_bad_request"
        '401':
          description: Unauthorized or token expired
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      operationId: updateGoogleBusinessLocationDetails
      tags: [GMB Location Details]
      summary: Update location details
      description: |
        Updates GBP location details. The updateMask field is required and specifies which fields to update.
        This endpoint proxies Google's Business Information API locations.patch, so any valid updateMask field is supported.
        Common fields: regularHours, specialHours, profile.description, websiteUri, phoneNumbers, categories, serviceItems.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
          description: The Zernio account ID (from /v1/accounts)
        - name: locationId
          in: query
          schema: { type: string }
          description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [updateMask]
              properties:
                updateMask:
                  type: string
                  description: "Required. Comma-separated fields to update (e.g. 'regularHours', 'specialHours', 'profile.description', 'categories', 'serviceItems'). Any valid Google Business Information API updateMask field is supported."
                regularHours:
                  type: object
                  properties:
                    periods:
                      type: array
                      items:
                        type: object
                        properties:
                          openDay: { type: string }
                          openTime: { type: string }
                          closeDay: { type: string }
                          closeTime: { type: string }
                specialHours:
                  type: object
                  properties:
                    specialHourPeriods:
                      type: array
                      items:
                        type: object
                        properties:
                          startDate: { type: object, properties: { year: { type: integer }, month: { type: integer }, day: { type: integer } } }
                          endDate: { type: object, properties: { year: { type: integer }, month: { type: integer }, day: { type: integer } } }
                          openTime: { type: string }
                          closeTime: { type: string }
                          closed: { type: boolean }
                profile:
                  type: object
                  properties:
                    description: { type: string }
                websiteUri: { type: string }
                phoneNumbers:
                  type: object
                  properties:
                    primaryPhone: { type: string }
                    additionalPhones: { type: array, items: { type: string } }
                categories:
                  type: object
                  description: "Primary and additional business categories. Use updateMask='categories' to update."
                  properties:
                    primaryCategory:
                      type: object
                      properties:
                        name:
                          type: string
                          description: "Category resource name (e.g. 'categories/gcid:laundromat'). Use Google's Categories API to look up valid IDs."
                    additionalCategories:
                      type: array
                      items:
                        type: object
                        properties:
                          name:
                            type: string
                            description: "Category resource name (e.g. 'categories/gcid:dry_cleaner')"
                serviceItems:
                  type: array
                  description: "Services offered by the business. Use updateMask='serviceItems' to update."
                  items:
                    type: object
                    properties:
                      structuredServiceItem:
                        type: object
                        description: "A predefined service from Google's service type catalog"
                        properties:
                          serviceTypeId:
                            type: string
                            description: "Service type ID from Google's catalog (e.g. 'job_type_id:plumbing_drain_repair')"
                          description:
                            type: string
                            description: "Optional description of the service"
                      freeFormServiceItem:
                        type: object
                        description: "A custom service not in Google's catalog"
                        properties:
                          category:
                            type: string
                            description: "Category resource name this service belongs to (e.g. 'categories/gcid:laundromat')"
                          label:
                            type: object
                            properties:
                              displayName:
                                type: string
                                description: "Service name as displayed to users"
                              languageCode:
                                type: string
                                description: "Language code (e.g. 'en')"
                      price:
                        type: object
                        description: "Optional price for the service"
                        properties:
                          currencyCode: { type: string, description: "ISO 4217 currency code (e.g. 'USD')" }
                          units: { type: string, description: "Whole units of the amount" }
                          nanos: { type: integer, description: "Nano units (10^-9) of the amount" }
            examples:
              updateHours:
                summary: Update business hours
                value:
                  updateMask: "regularHours,specialHours"
                  regularHours:
                    periods:
                      - openDay: "MONDAY"
                        openTime: "09:00"
                        closeDay: "MONDAY"
                        closeTime: "17:00"
                      - openDay: "SATURDAY"
                        openTime: "10:00"
                        closeDay: "SATURDAY"
                        closeTime: "14:00"
                  specialHours:
                    specialHourPeriods:
                      - startDate: { year: 2026, month: 12, day: 25 }
                        closed: true
                      - startDate: { year: 2026, month: 12, day: 31 }
                        openTime: "09:00"
                        closeTime: "15:00"
              updateCategories:
                summary: Update business categories
                value:
                  updateMask: "categories"
                  categories:
                    primaryCategory:
                      name: "categories/gcid:laundromat"
                    additionalCategories:
                      - name: "categories/gcid:dry_cleaner"
                      - name: "categories/gcid:laundry_service"
              updateServices:
                summary: Update service items
                value:
                  updateMask: "serviceItems"
                  serviceItems:
                    - structuredServiceItem:
                        serviceTypeId: "job_type_id:plumbing_drain_repair"
                        description: "Full drain cleaning and repair service"
                    - freeFormServiceItem:
                        category: "categories/gcid:laundromat"
                        label:
                          displayName: "Wash & Fold Service"
                          languageCode: "en"
                      price:
                        currencyCode: "USD"
                        units: "25"
      responses:
        '200':
          description: Location updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  accountId: { type: string }
                  locationId: { type: string }
        '400':
          description: Invalid request or missing updateMask
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized or token expired
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/accounts/{accountId}/gmb-media:
    get:
      operationId: listGoogleBusinessMedia
      tags: [GMB Media]
      summary: List media
      description: |
        Lists media items (photos) for a Google Business Profile location.
        Returns photo URLs, descriptions, categories, and metadata.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
        - name: locationId
          in: query
          schema: { type: string }
          description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
        - name: pageSize
          in: query
          schema: { type: integer, maximum: 100, default: 100 }
          description: Number of items to return (max 100)
        - name: pageToken
          in: query
          schema: { type: string }
          description: Pagination token from previous response
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Media items fetched successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  accountId: { type: string }
                  locationId: { type: string }
                  mediaItems:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string, description: Resource name }
                        mediaFormat: { type: string, enum: [PHOTO, VIDEO] }
                        sourceUrl: { type: string }
                        googleUrl: { type: string, description: Google-hosted URL }
                        thumbnailUrl: { type: string }
                        description: { type: string }
                        createTime: { type: string, format: date-time }
                        locationAssociation:
                          type: object
                          properties:
                            category: { type: string }
                  nextPageToken: { type: string }
                  totalMediaItemsCount: { type: integer }
        '400':
          description: Invalid request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
    post:
      operationId: createGoogleBusinessMedia
      tags: [GMB Media]
      summary: Upload photo
      description: |
        Creates a media item (photo) for a location from a publicly accessible URL.

        Categories determine where the photo appears: COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, FOOD_AND_DRINK, MENU, PRODUCT, TEAMS, ADDITIONAL.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
        - name: locationId
          in: query
          schema: { type: string }
          description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [sourceUrl]
              properties:
                sourceUrl: { type: string, description: Publicly accessible image URL }
                mediaFormat: { type: string, enum: [PHOTO, VIDEO], default: PHOTO }
                description: { type: string, description: Photo description }
                category:
                  type: string
                  enum: [COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, FOOD_AND_DRINK, MENU, PRODUCT, TEAMS, ADDITIONAL]
                  description: Where the photo appears on the listing
            example:
              sourceUrl: "https://example.com/photos/restaurant-interior.jpg"
              description: "Dining area with outdoor seating"
              category: "INTERIOR"
      responses:
        '200':
          description: Media created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  name: { type: string }
                  mediaFormat: { type: string }
                  googleUrl: { type: string }
        '400':
          description: Invalid request or unsupported media format
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
    delete:
      operationId: deleteGoogleBusinessMedia
      tags: [GMB Media]
      summary: Delete photo
      description: Deletes a photo or media item from a GBP location.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
        - name: locationId
          in: query
          schema: { type: string }
          description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
        - name: mediaId
          in: query
          required: true
          schema: { type: string }
          description: The media item ID to delete
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Media deleted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  deleted: { type: boolean }
                  mediaId: { type: string }
        '400':
          description: Invalid request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /v1/accounts/{accountId}/gmb-attributes:
    get:
      operationId: getGoogleBusinessAttributes
      tags: [GMB Attributes]
      summary: Get attributes
      description: Returns GBP location attributes (amenities, services, accessibility, payment types). Available attributes vary by business category.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
        - name: locationId
          in: query
          schema: { type: string }
          description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Attributes fetched successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  accountId: { type: string }
                  locationId: { type: string }
                  attributes:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string, description: "Attribute identifier (e.g. has_delivery)" }
                        valueType: { type: string, description: "Value type (BOOL, ENUM, URL, REPEATED_ENUM)" }
                        values: { type: array, items: {} }
                        repeatedEnumValue:
                          type: object
                          properties:
                            setValues: { type: array, items: { type: string } }
                            unsetValues: { type: array, items: { type: string } }
              example:
                success: true
                attributes:
                  - name: "has_delivery"
                    valueType: "BOOL"
                    values: [true]
                  - name: "has_takeout"
                    valueType: "BOOL"
                    values: [true]
                  - name: "has_outdoor_seating"
                    valueType: "BOOL"
                    values: [true]
                  - name: "pay_credit_card_types_accepted"
                    valueType: "REPEATED_ENUM"
                    repeatedEnumValue:
                      setValues: ["visa", "mastercard", "amex"]
        '400':
          description: Invalid request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
    put:
      operationId: updateGoogleBusinessAttributes
      tags: [GMB Attributes]
      summary: Update attributes
      description: |
        Updates location attributes (amenities, services, etc.).

        The attributeMask specifies which attributes to update (comma-separated).
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
        - name: locationId
          in: query
          schema: { type: string }
          description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [attributes, attributeMask]
              properties:
                attributes:
                  type: array
                  items:
                    type: object
                    properties:
                      name: { type: string }
                      values: { type: array, items: {} }
                      repeatedEnumValue:
                        type: object
                        properties:
                          setValues: { type: array, items: { type: string } }
                          unsetValues: { type: array, items: { type: string } }
                attributeMask:
                  type: string
                  description: "Comma-separated attribute names to update (e.g. 'has_delivery,has_takeout')"
            example:
              attributes:
                - name: "has_delivery"
                  values: [true]
                - name: "has_takeout"
                  values: [true]
                - name: "has_outdoor_seating"
                  values: [false]
              attributeMask: "has_delivery,has_takeout,has_outdoor_seating"
      responses:
        '200':
          description: Attributes updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  accountId: { type: string }
                  locationId: { type: string }
                  attributes: { type: array, items: { type: object } }
        '400':
          description: Invalid request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /v1/accounts/{accountId}/gmb-place-actions:
    get:
      operationId: listGoogleBusinessPlaceActions
      tags: [GMB Place Actions]
      summary: List action links
      description: |
        Lists place action links for a Google Business Profile location.

        Place actions are the booking, ordering, and reservation buttons that appear on your listing.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
        - name: locationId
          in: query
          schema: { type: string }
          description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
        - name: pageSize
          in: query
          schema: { type: integer, maximum: 100, default: 100 }
        - name: pageToken
          in: query
          schema: { type: string }
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Place actions fetched successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  accountId: { type: string }
                  locationId: { type: string }
                  placeActionLinks:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string, description: Resource name }
                        uri: { type: string, description: Action URL }
                        placeActionType: { type: string }
                        createTime: { type: string, format: date-time }
                        updateTime: { type: string, format: date-time }
                  nextPageToken: { type: string }
              example:
                success: true
                placeActionLinks:
                  - name: "locations/123/placeActionLinks/456"
                    uri: "https://order.ubereats.com/joespizza"
                    placeActionType: "FOOD_ORDERING"
                  - name: "locations/123/placeActionLinks/789"
                    uri: "https://www.opentable.com/joespizza"
                    placeActionType: "DINING_RESERVATION"
        '400':
          description: Invalid request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
    post:
      operationId: createGoogleBusinessPlaceAction
      tags: [GMB Place Actions]
      summary: Create action link
      description: |
        Creates a place action link for a location.

        Available action types: APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
        - name: locationId
          in: query
          schema: { type: string }
          description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [uri, placeActionType]
              properties:
                uri: { type: string, description: The action URL }
                placeActionType:
                  type: string
                  enum: [APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE]
                  description: Type of action
            example:
              uri: "https://order.ubereats.com/joespizza"
              placeActionType: "FOOD_ORDERING"
      responses:
        '200':
          description: Place action created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  name: { type: string, description: Resource name of the created link }
                  uri: { type: string }
                  placeActionType: { type: string }
        '400':
          description: Invalid request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
    delete:
      operationId: deleteGoogleBusinessPlaceAction
      tags: [GMB Place Actions]
      summary: Delete action link
      description: Deletes a place action link (e.g. booking or ordering URL) from a GBP location.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
        - name: locationId
          in: query
          schema: { type: string }
          description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
        - name: name
          in: query
          required: true
          schema: { type: string }
          description: "The resource name of the place action link (e.g. locations/123/placeActionLinks/456)"
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Place action deleted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  deleted: { type: boolean }
                  name: { type: string }
        '400':
          description: Invalid request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
    patch:
      operationId: updateGoogleBusinessPlaceAction
      tags: [GMB Place Actions]
      summary: Update action link
      description: |
        Updates a place action link (change URL or action type).
        Only the fields included in the request body will be updated.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
        - name: locationId
          in: query
          schema: { type: string }
          description: Override which location to target. If omitted, uses the account's selected location.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  description: "Resource name of the place action link (e.g. locations/123/placeActionLinks/456)"
                uri:
                  type: string
                  description: New action URL
                placeActionType:
                  type: string
                  enum: [APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE]
                  description: New action type
            example:
              name: "locations/123/placeActionLinks/456"
              uri: "https://order.doordash.com/joespizza"
      responses:
        '200':
          description: Place action updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  name: { type: string }
                  uri: { type: string }
                  placeActionType: { type: string }
        '400':
          description: Invalid request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /v1/accounts/{accountId}/gmb-services:
    get:
      operationId: getGoogleBusinessServices
      tags: [GMB Services]
      summary: Get services
      description: |
        Gets the services offered by a Google Business Profile location.
        Returns an array of service items (structured or free-form with optional price).
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
        - name: locationId
          in: query
          schema: { type: string }
          description: Override which location to query. If omitted, uses the account's selected location.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Services fetched successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  accountId: { type: string }
                  locationId: { type: string }
                  services:
                    type: array
                    items:
                      type: object
                      properties:
                        structuredServiceItem:
                          type: object
                          properties:
                            serviceTypeId: { type: string }
                            description: { type: string }
                        freeFormServiceItem:
                          type: object
                          properties:
                            category: { type: string }
                            label:
                              type: object
                              properties:
                                displayName: { type: string }
                                description: { type: string }
                        price:
                          type: object
                          properties:
                            currencyCode: { type: string, example: "USD" }
                            units: { type: string, example: "50" }
                            nanos: { type: integer }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
    put:
      operationId: updateGoogleBusinessServices
      tags: [GMB Services]
      summary: Replace services
      description: |
        Replaces the entire service list for a location.
        Google's API requires full replacement; individual item updates are not supported.
        Each service can be structured (using a predefined serviceTypeId) or free-form (custom label).
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
        - name: locationId
          in: query
          schema: { type: string }
          description: Override which location to target. If omitted, uses the account's selected location.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [serviceItems]
              properties:
                serviceItems:
                  type: array
                  items:
                    type: object
                    properties:
                      structuredServiceItem:
                        type: object
                        properties:
                          serviceTypeId: { type: string }
                          description: { type: string }
                      freeFormServiceItem:
                        type: object
                        properties:
                          category: { type: string }
                          label:
                            type: object
                            properties:
                              displayName: { type: string }
                              description: { type: string }
                      price:
                        type: object
                        properties:
                          currencyCode: { type: string }
                          units: { type: string }
                          nanos: { type: integer }
            example:
              serviceItems:
                - freeFormServiceItem:
                    category: "categories/gcid:plumber"
                    label: { displayName: "Pipe Repair", description: "Emergency and scheduled pipe repair" }
                  price: { currencyCode: "USD", units: "150" }
      responses:
        '200':
          description: Services updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  services: { type: array, items: { type: object } }
        '400':
          description: Invalid request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /v1/accounts/{accountId}/gmb-reviews/batch:
    post:
      operationId: batchGetGoogleBusinessReviews
      tags: [GMB Reviews]
      summary: Batch get reviews
      description: |
        Fetches reviews across multiple locations in a single request.
        More efficient than calling GET /gmb-reviews per location for multi-location businesses.
        Reviews are grouped by location in the response.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [locationNames]
              properties:
                locationNames:
                  type: array
                  items: { type: string }
                  description: "Array of full location resource names (e.g. ['accounts/123/locations/456'])"
                pageSize:
                  type: integer
                  maximum: 50
                  default: 50
                  description: Number of reviews per page (max 50)
                pageToken:
                  type: string
                  description: Pagination token from previous response
            example:
              locationNames: ["accounts/123/locations/456", "accounts/123/locations/789"]
              pageSize: 50
      responses:
        '200':
          description: Batch reviews fetched successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  accountId: { type: string }
                  locationReviews:
                    type: array
                    items:
                      type: object
                      properties:
                        locationName: { type: string }
                        reviews: { type: array, items: { type: object } }
                        averageRating: { type: number }
                        totalReviewCount: { type: integer }
                  nextPageToken: { type: string }
        '400':
          description: Invalid request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /v1/connect/pending-data:
    get:
      operationId: getPendingOAuthData
      tags: [Connect]
      summary: Get pending OAuth data
      description: Fetch pending OAuth data for headless mode using the pendingDataToken from the redirect URL. One-time use, expires after 10 minutes. No authentication required.
      parameters:
        - name: token
          in: query
          required: true
          schema: { type: string }
          description: The pending data token from the OAuth redirect URL (pendingDataToken parameter)
      responses:
        '200':
          description: OAuth data fetched successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform:
                    type: string
                    description: The platform (e.g., "linkedin")
                  profileId:
                    type: string
                    description: The Zernio profile ID
                  tempToken:
                    type: string
                    description: Temporary access token for the platform
                  refreshToken:
                    type: string
                    description: Refresh token (if available)
                  expiresIn:
                    type: number
                    description: Token expiry in seconds
                  userProfile:
                    type: object
                    description: User profile data (id, username, displayName, profilePicture)
                  selectionType:
                    type: string
                    enum: [organizations, pages, boards, locations, profiles]
                    description: Type of selection data
                  organizations:
                    type: array
                    description: LinkedIn organizations (when selectionType is "organizations")
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        urn: { type: string }
                        name: { type: string }
                        vanityName: { type: string }
              example:
                platform: "linkedin"
                profileId: "abc123"
                tempToken: "AQV..."
                refreshToken: "AQW..."
                expiresIn: 5183999
                userProfile:
                  id: "ABC123"
                  username: "John Doe"
                  displayName: "John Doe"
                  profilePicture: "https://..."
                selectionType: "organizations"
                organizations:
                  - id: "12345"
                    urn: "urn:li:organization:12345"
                    name: "Acme Corp"
                    vanityName: "acme-corp"
                  - id: "67890"
                    urn: "urn:li:organization:67890"
                    name: "Example Inc"
                    vanityName: "example-inc"
        '400':
          description: Missing token parameter
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404':
          description: Token not found or expired
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /v1/connect/linkedin/organizations:
    get:
      operationId: listLinkedInOrganizations
      tags: [Connect]
      summary: List LinkedIn orgs
      description: Fetch full LinkedIn organization details (logos, vanity names, websites) for custom UI. No authentication required, just the tempToken from OAuth.
      parameters:
        - name: tempToken
          in: query
          required: true
          schema: { type: string }
          description: The temporary LinkedIn access token from the OAuth redirect
        - name: orgIds
          in: query
          required: true
          schema: { type: string }
          description: Comma-separated list of organization IDs to fetch details for (max 100)
          example: "12345678,87654321,11111111"
      responses:
        '200':
          description: Organization details fetched successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  organizations:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: Organization ID }
                        logoUrl: { type: string, format: uri, description: Logo URL (may be absent if no logo) }
                        vanityName: { type: string, description: Organization's vanity name/slug }
                        website: { type: string, format: uri, description: Organization's website URL }
                        industry: { type: string, description: Organization's primary industry }
                        description: { type: string, description: Organization's description }
              example:
                organizations:
                  - id: "12345678"
                    logoUrl: "https://media.licdn.com/dms/image/v2/..."
                    vanityName: "acme-corp"
                    website: "https://acme.com"
                    industry: "Technology"
                    description: "Leading provider of innovative solutions"
                  - id: "87654321"
                    logoUrl: "https://media.licdn.com/dms/image/v2/..."
                    vanityName: "example-inc"
                    website: "https://example.com"
                  - id: "11111111"
        '400':
          description: Missing required parameters or too many organization IDs
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
              example:
                error: "Missing tempToken parameter"
        '500':
          description: Failed to fetch organization details

  /v1/connect/linkedin/select-organization:
    post:
      operationId: selectLinkedInOrganization
      tags: [Connect]
      summary: Select LinkedIn org
      description: Complete the LinkedIn connection flow. Set accountType to "personal" or "organization" to connect as a company page. Use X-Connect-Token if connecting via API key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, tempToken, userProfile, accountType]
              properties:
                profileId: { type: string }
                tempToken: { type: string }
                userProfile: { type: object }
                accountType: { type: string, enum: [personal, organization] }
                selectedOrganization: { type: object }
                redirect_url: { type: string, format: uri }
            examples:
              personalAccount:
                summary: Connect as personal LinkedIn profile
                description: For personal accounts, set accountType to "personal" and omit selectedOrganization
                value:
                  profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
                  tempToken: "AQX..."
                  userProfile:
                    id: "abc123"
                    username: "johndoe"
                    displayName: "John Doe"
                    profilePicture: "https://media.licdn.com/dms/image/v2/..."
                  accountType: "personal"
              organizationAccount:
                summary: Connect as org/company page
                description: For organization pages, include the selectedOrganization object
                value:
                  profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
                  tempToken: "AQX..."
                  userProfile:
                    id: "abc123"
                    username: "johndoe"
                    displayName: "John Doe"
                    profilePicture: "https://media.licdn.com/dms/image/v2/..."
                  accountType: "organization"
                  selectedOrganization:
                    id: "12345678"
                    urn: "urn:li:organization:12345678"
                    name: "Acme Corporation"
                  redirect_url: "https://yourapp.com/callback"
      responses:
        '200':
          description: LinkedIn account connected
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  redirect_url:
                    type: string
                    description: The redirect URL with connection params appended (only if redirect_url was provided in request)
                  account:
                    type: object
                    properties:
                      accountId:
                        type: string
                        description: ID of the created SocialAccount
                      platform: { type: string, enum: [linkedin] }
                      username: { type: string }
                      displayName: { type: string }
                      profilePicture: { type: string }
                      isActive: { type: boolean }
                      accountType: { type: string, enum: [personal, organization] }
                  bulkRefresh:
                    type: object
                    properties:
                      updatedCount: { type: integer }
                      errors: { type: integer }
              examples:
                personalAccountResponse:
                  summary: Personal account connected
                  value:
                    message: "LinkedIn account connected successfully"
                    account:
                      accountId: "64e1f0a9e2b5af0012ab34cd"
                      platform: "linkedin"
                      username: "johndoe"
                      displayName: "John Doe"
                      profilePicture: "https://media.licdn.com/..."
                      isActive: true
                      accountType: "personal"
                organizationWithRedirect:
                  summary: Org account with redirect URL
                  value:
                    message: "LinkedIn account connected successfully"
                    redirect_url: "https://yourapp.com/callback?connected=linkedin&profileId=507f1f77bcf86cd799439011&accountId=64e1f0a9e2b5af0012ab34cd&username=Acme+Corporation"
                    account:
                      accountId: "64e1f0a9e2b5af0012ab34cd"
                      platform: "linkedin"
                      username: "acme-corp"
                      displayName: "Acme Corporation"
                      profilePicture: "https://media.licdn.com/..."
                      isActive: true
                      accountType: "organization"
                    bulkRefresh:
                      updatedCount: 5
                      errors: 0
        '400': { description: Missing required fields }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { description: Failed to connect LinkedIn account }

  /v1/connect/pinterest/select-board:
    get:
      operationId: listPinterestBoardsForSelection
      tags: [Connect]
      summary: List Pinterest boards
      description: For headless flows. Returns Pinterest boards the user can post to. Use X-Connect-Token from the redirect URL.
      parameters:
        - name: X-Connect-Token
          in: header
          required: true
          schema: { type: string }
          description: Short-lived connect token from the OAuth redirect
        - name: profileId
          in: query
          required: true
          schema: { type: string }
          description: Your Zernio profile ID
        - name: tempToken
          in: query
          required: true
          schema: { type: string }
          description: Temporary Pinterest access token from the OAuth callback redirect
      responses:
        '200':
          description: List of Pinterest Boards available for connection
          content:
            application/json:
              schema:
                type: object
                properties:
                  boards:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: Pinterest Board ID }
                        name: { type: string, description: Board name }
                        description: { type: string, description: Board description }
                        privacy: { type: string, description: Board privacy setting }
              example:
                boards:
                  - id: "123456789012345678"
                    name: "Marketing Ideas"
                    description: "Collection of marketing inspiration"
                    privacy: "PUBLIC"
                  - id: "234567890123456789"
                    name: "Product Photos"
                    description: "Product photography"
                    privacy: "PUBLIC"
        '400': { description: Missing required parameters }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: No access to profile }
        '500': { description: Failed to fetch boards }
    post:
      operationId: selectPinterestBoard
      tags: [Connect]
      summary: Select Pinterest board
      description: |
        Complete the Pinterest connection flow. After OAuth, use this endpoint to save the selected board and complete the account connection. Use the X-Connect-Token header if you initiated the connection via API key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, boardId, tempToken]
              properties:
                profileId:
                  type: string
                  description: Your Zernio profile ID
                boardId:
                  type: string
                  description: The Pinterest Board ID selected by the user
                boardName:
                  type: string
                  description: The board name (for display purposes)
                tempToken:
                  type: string
                  description: Temporary Pinterest access token from OAuth
                userProfile:
                  type: object
                  description: User profile data from OAuth redirect
                refreshToken:
                  type: string
                  description: Pinterest refresh token (if available)
                expiresIn:
                  type: integer
                  description: Token expiration time in seconds
                redirect_url:
                  type: string
                  format: uri
                  description: Custom redirect URL after connection completes
            example:
              profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
              boardId: "123456789012345678"
              boardName: "Marketing Ideas"
              tempToken: "pina_..."
              userProfile:
                id: "user123"
                username: "mybrand"
                displayName: "My Brand"
                profilePicture: "https://i.pinimg.com/..."
              redirect_url: "https://yourapp.com/callback"
      responses:
        '200':
          description: Pinterest Board connected successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  redirect_url: { type: string, description: Redirect URL with connection params (if provided) }
                  account:
                    type: object
                    properties:
                      accountId:
                        type: string
                        description: ID of the created SocialAccount
                      platform: { type: string, enum: [pinterest] }
                      username: { type: string }
                      displayName: { type: string }
                      profilePicture: { type: string }
                      isActive: { type: boolean }
                      defaultBoardName: { type: string }
              example:
                message: "Pinterest connected successfully with default board"
                redirect_url: "https://yourdomain.com/integrations/callback?connected=pinterest&profileId=507f1f77bcf86cd799439011&board=Marketing+Ideas"
                account:
                  accountId: "64e1f0a9e2b5af0012ab34cd"
                  platform: "pinterest"
                  username: "mybrand"
                  displayName: "My Brand"
                  profilePicture: "https://i.pinimg.com/..."
                  isActive: true
                  defaultBoardName: "Marketing Ideas"
        '400':
          description: Missing required fields
          content:
            application/json:
              example:
                error: "Missing required fields"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: No access to profile or profile limit exceeded
          content:
            application/json:
              examples:
                forbidden:
                  value: { error: "Forbidden" }
                limitExceeded:
                  value:
                    error: "Cannot connect to this profile. It exceeds your Pro plan limit of 5 profiles."
                    code: "PROFILE_LIMIT_EXCEEDED"
        '500':
          description: Failed to save Pinterest connection

  /v1/connect/snapchat/select-profile:
    get:
      operationId: listSnapchatProfiles
      tags: [Connect]
      summary: List Snapchat profiles
      description: For headless flows. Returns Snapchat Public Profiles the user can post to. Use X-Connect-Token from the redirect URL.
      parameters:
        - name: X-Connect-Token
          in: header
          required: true
          schema: { type: string }
          description: Short-lived connect token from the OAuth redirect
        - name: profileId
          in: query
          required: true
          schema: { type: string }
          description: Your Zernio profile ID
        - name: tempToken
          in: query
          required: true
          schema: { type: string }
          description: Temporary Snapchat access token from the OAuth callback redirect
      responses:
        '200':
          description: List of Snapchat Public Profiles available for connection
          content:
            application/json:
              schema:
                type: object
                properties:
                  publicProfiles:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: Snapchat Public Profile ID }
                        display_name: { type: string, description: Public profile display name }
                        username: { type: string, description: Public profile username/handle }
                        profile_image_url: { type: string, description: Profile image URL }
                        subscriber_count: { type: integer, description: Number of subscribers }
              example:
                publicProfiles:
                  - id: "abc123-def456"
                    display_name: "My Brand"
                    username: "mybrand"
                    profile_image_url: "https://cf-st.sc-cdn.net/..."
                    subscriber_count: 15000
                  - id: "xyz789-uvw012"
                    display_name: "Side Project"
                    username: "sideproject"
                    profile_image_url: "https://cf-st.sc-cdn.net/..."
                    subscriber_count: 5000
        '400': { description: Missing required parameters (profileId or tempToken) }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: No access to profile }
        '500': { description: Failed to fetch public profiles }
    post:
      operationId: selectSnapchatProfile
      tags: [Connect]
      summary: Select Snapchat profile
      description: Complete the Snapchat connection flow by saving the selected Public Profile. Snapchat requires a Public Profile to publish content. Use X-Connect-Token if connecting via API key.
      parameters:
        - name: X-Connect-Token
          in: header
          required: false
          schema: { type: string }
          description: Short-lived connect token from the OAuth redirect (for API users)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, selectedPublicProfile, tempToken, userProfile]
              properties:
                profileId:
                  type: string
                  description: Your Zernio profile ID
                selectedPublicProfile:
                  type: object
                  description: The selected Snapchat Public Profile
                  required: [id, display_name]
                  properties:
                    id:
                      type: string
                      description: Snapchat Public Profile ID
                    display_name:
                      type: string
                      description: Display name of the public profile
                    username:
                      type: string
                      description: Username/handle
                    profile_image_url:
                      type: string
                      description: Profile image URL
                    subscriber_count:
                      type: integer
                      description: Number of subscribers
                tempToken:
                  type: string
                  description: Temporary Snapchat access token from OAuth
                userProfile:
                  type: object
                  description: User profile data from OAuth redirect
                refreshToken:
                  type: string
                  description: Snapchat refresh token (if available)
                expiresIn:
                  type: integer
                  description: Token expiration time in seconds
                redirect_url:
                  type: string
                  format: uri
                  description: Custom redirect URL after connection completes
            example:
              profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
              selectedPublicProfile:
                id: "abc123-def456"
                display_name: "My Brand"
                username: "mybrand"
                profile_image_url: "https://cf-st.sc-cdn.net/..."
                subscriber_count: 15000
              tempToken: "eyJ..."
              userProfile:
                id: "user123"
                username: "mybrand"
                displayName: "My Brand"
                profilePicture: "https://cf-st.sc-cdn.net/..."
              redirect_url: "https://yourapp.com/callback"
      responses:
        '200':
          description: Snapchat Public Profile connected successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  redirect_url: { type: string, description: Redirect URL with connection params (if provided in request) }
                  account:
                    type: object
                    properties:
                      accountId:
                        type: string
                        description: ID of the created SocialAccount
                      platform: { type: string, enum: [snapchat] }
                      username: { type: string }
                      displayName: { type: string }
                      profilePicture: { type: string }
                      isActive: { type: boolean }
                      publicProfileName: { type: string }
              example:
                message: "Snapchat connected successfully with public profile"
                redirect_url: "https://yourdomain.com/integrations/callback?connected=snapchat&profileId=507f1f77bcf86cd799439011&publicProfile=My+Brand"
                account:
                  accountId: "64e1f0a9e2b5af0012ab34cd"
                  platform: "snapchat"
                  username: "mybrand"
                  displayName: "My Brand"
                  profilePicture: "https://cf-st.sc-cdn.net/..."
                  isActive: true
                  publicProfileName: "My Brand"
        '400':
          description: Missing required fields
          content:
            application/json:
              example:
                error: "Missing required fields"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: No access to profile or profile limit exceeded
          content:
            application/json:
              examples:
                forbidden:
                  value: { error: "Forbidden" }
                limitExceeded:
                  value:
                    error: "Cannot connect to this profile. It exceeds your Pro plan limit of 5 profiles."
                    code: "PROFILE_LIMIT_EXCEEDED"
        '500':
          description: Failed to connect Snapchat account

  /v1/connect/bluesky/credentials:
    post:
      operationId: connectBlueskyCredentials
      tags: [Connect]
      summary: Connect Bluesky account
      description: |
        Connect a Bluesky account using identifier (handle or email) and an app password.
        To get your userId for the state parameter, call GET /v1/users which includes a currentUserId field.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [identifier, appPassword, state]
              properties:
                identifier:
                  type: string
                  description: Your Bluesky handle (e.g. user.bsky.social) or email address
                appPassword:
                  type: string
                  description: App password generated from Bluesky Settings > App Passwords
                state:
                  type: string
                  description: Required state formatted as {userId}-{profileId}. Get userId from GET /v1/users and profileId from GET /v1/profiles.
                  example: "6507a1b2c3d4e5f6a7b8c9d0-6507a1b2c3d4e5f6a7b8c9d1"
                redirectUri:
                  type: string
                  format: uri
                  description: Optional URL to redirect to after successful connection
            example:
              identifier: "yourhandle.bsky.social"
              appPassword: "xxxx-xxxx-xxxx-xxxx"
              state: "6507a1b2c3d4e5f6a7b8c9d0-6507a1b2c3d4e5f6a7b8c9d1"
              redirectUri: "https://yourapp.com/connected"
      responses:
        '200':
          description: Bluesky connected successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  account: { $ref: '#/components/schemas/SocialAccount' }
              example:
                message: "Bluesky connected successfully"
                account:
                  platform: "bluesky"
                  username: "yourhandle.bsky.social"
                  displayName: "Your Name"
                  isActive: true
                redirectUrl: "https://zernio.com/dashboard/profiles/64f0.../accounts"
        '400': { description: Invalid request - missing fields or invalid state format }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500': { description: Internal error }

  /v1/connect/whatsapp/credentials:
    post:
      operationId: connectWhatsAppCredentials
      tags: [Connect]
      summary: Connect WhatsApp via credentials
      description: |
        Connect a WhatsApp Business Account by providing Meta credentials directly.
        This is the headless alternative to the Embedded Signup browser flow.

        To get the required credentials:
        1. Go to Meta Business Suite (business.facebook.com)
        2. Create or select a WhatsApp Business Account
        3. In Business Settings > System Users, create a System User
        4. Assign it the whatsapp_business_management and whatsapp_business_messaging permissions
        5. Generate a permanent access token
        6. Get the WABA ID from WhatsApp Manager > Account Tools > Phone Numbers
        7. Get the Phone Number ID from the same page (click on the number)
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, accessToken, wabaId, phoneNumberId]
              properties:
                profileId:
                  type: string
                  description: Your Zernio profile ID
                accessToken:
                  type: string
                  description: Permanent System User access token from Meta Business Suite
                wabaId:
                  type: string
                  description: WhatsApp Business Account ID from Meta
                phoneNumberId:
                  type: string
                  description: Phone Number ID from Meta WhatsApp Manager
            example:
              profileId: "6507a1b2c3d4e5f6a7b8c9d0"
              accessToken: "EAABsbCS...your-system-user-token"
              wabaId: "123456789012345"
              phoneNumberId: "987654321098765"
      responses:
        '200':
          description: WhatsApp connected successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  account:
                    type: object
                    properties:
                      accountId: { type: string }
                      platform: { type: string, enum: [whatsapp] }
                      username: { type: string, description: Display phone number }
                      displayName: { type: string, description: Meta-verified business name }
                      isActive: { type: boolean }
                      selectedPhoneNumber: { type: string, description: The connected phone number }
              example:
                message: "WhatsApp connected successfully"
                account:
                  accountId: "6507a1b2c3d4e5f6a7b8c9d0"
                  platform: "whatsapp"
                  username: "+1 555-123-4567"
                  displayName: "Acme Corp"
                  isActive: true
                  selectedPhoneNumber: "+1 555-123-4567"
        '400':
          description: |
            Invalid request. Either missing fields or the phoneNumberId was not found
            in the specified WABA. If the phone was not found, the response includes
            availablePhoneNumbers to help identify the correct ID.
        '401':
          description: Invalid or expired access token
        '403':
          description: Profile limit exceeded for this plan

  /v1/connect/telegram:
    get:
      operationId: getTelegramConnectStatus
      tags: [Connect]
      summary: Generate Telegram code
      description: Generate an access code (valid 15 minutes) for connecting a Telegram channel or group. Add the bot as admin, then send the code + @yourchannel to the bot. Poll PATCH /v1/connect/telegram to check status.
      parameters:
        - name: profileId
          in: query
          required: true
          schema: { type: string }
          description: The profile ID to connect the Telegram account to
      responses:
        '200':
          description: Access code generated
          content:
            application/json:
              schema:
                type: object
                properties:
                  code:
                    type: string
                    description: The access code to send to the Telegram bot
                    example: "ZRN-ABC123"
                  expiresAt:
                    type: string
                    format: date-time
                    description: When the code expires
                  expiresIn:
                    type: integer
                    description: Seconds until expiration
                    example: 900
                  botUsername:
                    type: string
                    description: The Telegram bot username to message
                    example: "LateScheduleBot"
                  instructions:
                    type: array
                    items: { type: string }
                    description: Step-by-step connection instructions
              example:
                code: "ZRN-ABC123"
                expiresAt: "2024-01-15T12:30:00.000Z"
                expiresIn: 900
                botUsername: "LateScheduleBot"
                instructions:
                  - "1. Add @ZernioScheduleBot as an administrator in your channel/group"
                  - "2. Open a private chat with @ZernioScheduleBot"
                  - "3. Send: ZRN-ABC123 @yourchannel (replace @yourchannel with your channel username)"
                  - "4. Wait for confirmation - the connection will appear in your dashboard"
                  - "Tip: If your channel has no public username, forward a message from it along with the code"
        '400': { description: Profile ID required or invalid format }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: No access to this profile }
        '404': { description: Profile not found }
        '500': { description: Internal error }
    post:
      operationId: initiateTelegramConnect
      tags: [Connect]
      summary: Connect Telegram directly
      description: Connect a Telegram channel/group directly using the chat ID. Alternative to the access code flow. The bot must already be an admin in the channel/group.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [chatId, profileId]
              properties:
                chatId:
                  type: string
                  description: The Telegram chat ID. Numeric ID (e.g. "-1001234567890") or username with @ prefix (e.g. "@mychannel").
                profileId:
                  type: string
                  description: The profile ID to connect the account to
            example:
              chatId: "-1001234567890"
              profileId: "6507a1b2c3d4e5f6a7b8c9d0"
      responses:
        '200':
          description: Telegram channel connected successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  account:
                    type: object
                    properties:
                      _id: { type: string }
                      platform: { type: string, enum: [telegram] }
                      username: { type: string }
                      displayName: { type: string }
                      isActive: { type: boolean }
                      chatType: { type: string, enum: [channel, group, supergroup, private] }
              example:
                message: "Telegram channel connected successfully"
                account:
                  _id: "64e1f0a9e2b5af0012ab34cd"
                  platform: "telegram"
                  username: "mychannel"
                  displayName: "My Channel"
                  isActive: true
                  chatType: "channel"
        '400': { description: Chat ID required, bot not admin, or cannot access chat }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: No access to this profile }
        '404': { description: Profile not found }
        '500': { description: Internal error }
    patch:
      operationId: completeTelegramConnect
      tags: [Connect]
      summary: Check Telegram status
      description: |
        Poll this endpoint to check if a Telegram access code has been used to connect a channel/group. Recommended polling interval: 3 seconds.
        Status values: pending (waiting for user), connected (channel/group linked), expired (generate a new code).
      parameters:
        - name: code
          in: query
          required: true
          schema: { type: string }
          description: The access code to check status for
          example: "ZRN-ABC123"
      responses:
        '200':
          description: Connection status
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    title: Pending
                    properties:
                      status: { type: string, enum: [pending] }
                      expiresAt: { type: string, format: date-time }
                      expiresIn: { type: integer, description: Seconds until expiration }
                  - type: object
                    title: Connected
                    properties:
                      status: { type: string, enum: [connected] }
                      chatId: { type: string }
                      chatTitle: { type: string }
                      chatType: { type: string, enum: [channel, group, supergroup] }
                      account:
                        type: object
                        properties:
                          _id: { type: string }
                          platform: { type: string }
                          username: { type: string }
                          displayName: { type: string }
                  - type: object
                    title: Expired
                    properties:
                      status: { type: string, enum: [expired] }
                      message: { type: string }
              examples:
                pending:
                  summary: Waiting for connection
                  value:
                    status: "pending"
                    expiresAt: "2024-01-15T12:30:00.000Z"
                    expiresIn: 542
                connected:
                  summary: Successfully connected
                  value:
                    status: "connected"
                    chatId: "-1001234567890"
                    chatTitle: "My Channel"
                    chatType: "channel"
                    account:
                      _id: "64e1f0a9e2b5af0012ab34cd"
                      platform: "telegram"
                      username: "mychannel"
                      displayName: "My Channel"
                expired:
                  summary: Code expired
                  value:
                    status: "expired"
                    message: "Access code has expired. Please generate a new one."
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Code not found }
        '500': { description: Internal error }

  /v1/accounts/{accountId}/facebook-page:
    get:
      operationId: getFacebookPages
      tags: [Connect]
      summary: List Facebook pages
      description: Returns all Facebook pages the connected account has access to, including the currently selected page.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Pages list
          content:
            application/json:
              schema:
                type: object
                properties:
                  pages:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        username: { type: string }
                        category: { type: string }
                        fan_count: { type: integer }
                  selectedPageId: { type: string }
                  cached: { type: boolean }
              example:
                pages:
                  - id: "123456789012345"
                    name: "My Brand Page"
                    username: "mybrand"
                    category: "Brand"
                    fan_count: 5000
                  - id: "234567890123456"
                    name: "My Other Page"
                    username: "myotherpage"
                    category: "Business"
                    fan_count: 1200
                selectedPageId: "123456789012345"
                cached: true
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
    put:
      operationId: updateFacebookPage
      tags: [Connect]
      summary: Update Facebook page
      description: Switch which Facebook Page is active for a connected account.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [selectedPageId]
              properties:
                selectedPageId: { type: string }
            example:
              selectedPageId: "123456789012345"
      responses:
        '200':
          description: Page updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  selectedPage:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
              example:
                message: "Facebook page updated successfully"
                selectedPage:
                  id: "123456789012345"
                  name: "My Brand Page"
        '400': { description: Page not in available pages }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }

  /v1/accounts/{accountId}/linkedin-organizations:
    get:
      operationId: getLinkedInOrganizations
      tags: [Connect]
      summary: List LinkedIn orgs
      description: Returns LinkedIn organizations (company pages) the connected account has admin access to.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Organizations list
          content:
            application/json:
              schema:
                type: object
                properties:
                  organizations:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        vanityName: { type: string }
                        localizedName: { type: string }
              example:
                organizations:
                  - id: "12345678"
                    name: "Acme Corporation"
                    vanityName: "acme-corp"
                    localizedName: "Acme Corporation"
                  - id: "87654321"
                    name: "Acme Subsidiary"
                    vanityName: "acme-sub"
                    localizedName: "Acme Subsidiary"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }

  /v1/accounts/{accountId}/linkedin-aggregate-analytics:
    get:
      operationId: getLinkedInAggregateAnalytics
      tags: [Analytics]
      summary: Get LinkedIn aggregate stats
      description: Returns aggregate analytics across all posts for a LinkedIn personal account. Only includes posts published through Zernio (LinkedIn API limitation). Org accounts should use /v1/analytics instead. Requires r_member_postAnalytics scope. Saves (POST_SAVE) and sends (POST_SEND) are available for personal accounts; organization pages always return 0 for these two metrics because LinkedIn does not expose them on the organization analytics endpoint.
      parameters:
        - name: accountId
          in: path
          required: true
          description: The ID of the LinkedIn personal account
          schema: { type: string }
        - name: aggregation
          in: query
          required: false
          description: TOTAL (default, lifetime totals) or DAILY (time series). MEMBERS_REACHED not available with DAILY.
          schema:
            type: string
            enum: [TOTAL, DAILY]
            default: TOTAL
        - name: startDate
          in: query
          required: false
          description: Start date (YYYY-MM-DD). If omitted, returns lifetime analytics.
          schema:
            type: string
            format: date
          example: "2024-01-01"
        - name: endDate
          in: query
          required: false
          description: End date (YYYY-MM-DD, exclusive). Defaults to today if omitted.
          schema:
            type: string
            format: date
          example: "2024-01-31"
        - name: metrics
          in: query
          required: false
          description: "Comma-separated metrics: IMPRESSION, MEMBERS_REACHED, REACTION, COMMENT, RESHARE, POST_SAVE, POST_SEND. Omit for all."
          schema:
            type: string
          example: "IMPRESSION,REACTION,COMMENT,POST_SAVE,POST_SEND"
      responses:
        '200':
          description: Aggregate analytics data
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/LinkedInAggregateAnalyticsTotalResponse'
                  - $ref: '#/components/schemas/LinkedInAggregateAnalyticsDailyResponse'
              examples:
                totalAggregation:
                  summary: TOTAL aggregation (lifetime totals)
                  value:
                    accountId: "64abc123def456"
                    platform: "linkedin"
                    accountType: "personal"
                    username: "John Doe"
                    aggregation: "TOTAL"
                    dateRange: null
                    analytics:
                      impressions: 1250000
                      reach: 450000
                      reactions: 7500
                      comments: 2500
                      shares: 1200
                      saves: 3400
                      sends: 900
                      engagementRate: 1.24
                    note: "Aggregate analytics across all posts on this LinkedIn personal account (lifetime totals)."
                    lastUpdated: "2025-01-15T10:30:00.000Z"
                totalWithDateRange:
                  summary: TOTAL aggregation with date range
                  value:
                    accountId: "64abc123def456"
                    platform: "linkedin"
                    accountType: "personal"
                    username: "John Doe"
                    aggregation: "TOTAL"
                    dateRange:
                      startDate: "2024-01-01"
                      endDate: "2024-01-31"
                    analytics:
                      impressions: 125000
                      reach: 45000
                      reactions: 750
                      comments: 250
                      shares: 120
                      saves: 340
                      sends: 90
                      engagementRate: 1.24
                    note: "Aggregate analytics for the specified date range."
                    lastUpdated: "2025-01-15T10:30:00.000Z"
                dailyAggregation:
                  summary: DAILY aggregation (time series)
                  value:
                    accountId: "64abc123def456"
                    platform: "linkedin"
                    accountType: "personal"
                    username: "John Doe"
                    aggregation: "DAILY"
                    dateRange:
                      startDate: "2024-05-04"
                      endDate: "2024-05-06"
                    analytics:
                      impressions:
                        - date: "2024-05-04"
                          count: 1500
                        - date: "2024-05-05"
                          count: 2300
                      reactions:
                        - date: "2024-05-04"
                          count: 10
                        - date: "2024-05-05"
                          count: 20
                      comments:
                        - date: "2024-05-04"
                          count: 3
                        - date: "2024-05-05"
                          count: 5
                      shares:
                        - date: "2024-05-04"
                          count: 2
                        - date: "2024-05-05"
                          count: 4
                      saves:
                        - date: "2024-05-04"
                          count: 8
                        - date: "2024-05-05"
                          count: 12
                      sends:
                        - date: "2024-05-04"
                          count: 1
                        - date: "2024-05-05"
                          count: 3
                    skippedMetrics:
                      - "MEMBERS_REACHED (not supported with DAILY aggregation)"
                    note: "Daily breakdown of analytics across all posts. MEMBERS_REACHED is not available with DAILY aggregation per LinkedIn API limitations."
                    lastUpdated: "2025-01-15T10:30:00.000Z"
        '400':
          description: Invalid request
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string }
                  validOptions: { type: array, items: { type: string } }
              examples:
                not_linkedin:
                  summary: Not a LinkedIn account
                  value:
                    error: "This endpoint only supports LinkedIn accounts"
                    code: "invalid_platform"
                organization:
                  summary: Org account not supported
                  value:
                    error: "Aggregate analytics only available for LinkedIn personal accounts. Organization accounts can use per-post analytics via /v1/analytics."
                    code: "organization_not_supported"
                invalid_aggregation:
                  summary: Invalid aggregation type
                  value:
                    error: "Invalid aggregation type. Must be one of: TOTAL, DAILY"
                    code: "invalid_aggregation"
                    validOptions: ["TOTAL", "DAILY"]
                invalid_date:
                  summary: Invalid date format
                  value:
                    error: "Invalid date format. Use YYYY-MM-DD format."
                    code: "invalid_date_format"
                    example:
                      startDate: "2024-01-01"
                      endDate: "2024-01-31"
                invalid_metrics:
                  summary: Invalid metrics requested
                  value:
                    error: "Invalid metrics: INVALID_METRIC. Valid options: IMPRESSION, MEMBERS_REACHED, REACTION, COMMENT, RESHARE, POST_SAVE, POST_SEND"
                    code: "invalid_metrics"
                    validOptions: ["IMPRESSION", "MEMBERS_REACHED", "REACTION", "COMMENT", "RESHARE", "POST_SAVE", "POST_SEND"]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics add-on required
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string }
        '403':
          description: Missing required LinkedIn scope
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, example: missing_scope }
                  requiredScope: { type: string, example: r_member_postAnalytics }
                  action: { type: string, example: reconnect }
              example:
                error: "Missing r_member_postAnalytics scope. Please reconnect your LinkedIn account to grant analytics permissions."
                code: "missing_scope"
                requiredScope: "r_member_postAnalytics"
                action: "reconnect"
        '404': { description: Account not found }

  /v1/accounts/{accountId}/linkedin-post-analytics:
    get:
      operationId: getLinkedInPostAnalytics
      tags: [Analytics]
      summary: Get LinkedIn post stats
      description: Returns analytics for a specific LinkedIn post by URN. Works for both personal and organization accounts. Saves and sends are only populated for personal accounts (LinkedIn does not expose these metrics on the organization analytics endpoint).
      parameters:
        - name: accountId
          in: path
          required: true
          description: The ID of the LinkedIn account
          schema: { type: string }
        - name: urn
          in: query
          required: true
          description: The LinkedIn post URN
          schema: { type: string }
          example: "urn:li:share:7123456789012345678"
      responses:
        '200':
          description: Post analytics data
          content:
            application/json:
              schema:
                type: object
                properties:
                  accountId: { type: string }
                  platform: { type: string, example: linkedin }
                  accountType: { type: string, enum: [personal, organization] }
                  username: { type: string }
                  postUrn: { type: string }
                  analytics:
                    type: object
                    properties:
                      impressions: { type: integer, description: Times the post was shown }
                      reach: { type: integer, description: Unique members who saw the post }
                      likes: { type: integer, description: Reactions on the post }
                      comments: { type: integer, description: Comments on the post }
                      shares: { type: integer, description: Reshares of the post }
                      saves: { type: integer, description: Times the post was saved (personal accounts only; 0 for organization accounts) }
                      sends: { type: integer, description: Times the post was sent via LinkedIn messaging (personal accounts only; 0 for organization accounts) }
                      clicks: { type: integer, description: Clicks on the post (organization accounts only) }
                      views: { type: integer, description: Video views (video posts only) }
                      engagementRate: { type: number, description: Engagement rate as percentage }
                  lastUpdated: { type: string, format: date-time }
              example:
                accountId: "64abc123def456"
                platform: "linkedin"
                accountType: "personal"
                username: "John Doe"
                postUrn: "urn:li:share:7123456789012345678"
                analytics:
                  impressions: 5420
                  reach: 3200
                  likes: 156
                  comments: 23
                  shares: 12
                  saves: 48
                  sends: 9
                  clicks: 0
                  views: 1250
                  engagementRate: 6.22
                lastUpdated: "2025-01-15T10:30:00.000Z"
        '400':
          description: Invalid request
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, enum: [missing_urn, invalid_urn, invalid_platform] }
              examples:
                missing_urn:
                  value:
                    error: "Missing required parameter: urn"
                    code: "missing_urn"
                    example: "urn:li:share:7123456789012345678 or urn:li:ugcPost:7123456789012345678"
                invalid_urn:
                  value:
                    error: "Invalid URN format. Must be urn:li:share:ID or urn:li:ugcPost:ID"
                    code: "invalid_urn"
                    providedUrn: "invalid-urn"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics add-on required
        '403':
          description: Missing required LinkedIn scope
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, example: missing_scope }
                  requiredScope: { type: string }
                  action: { type: string, example: reconnect }
        '404':
          description: Account or post not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string }
              examples:
                account_not_found:
                  value:
                    error: "Account not found"
                post_not_found:
                  value:
                    error: "Post not found. The URN may be invalid or the post may have been deleted."
                    code: "post_not_found"
                    postUrn: "urn:li:share:123"

  /v1/accounts/{accountId}/linkedin-post-reactions:
    get:
      operationId: getLinkedInPostReactions
      tags: [Analytics]
      summary: Get LinkedIn post reactions
      description: |
        Returns individual reactions for a specific LinkedIn post, including reactor profiles
        (name, headline/job title, profile picture, profile URL, reaction type).
        Only works for organization/company page accounts. LinkedIn restricts reaction
        data for personal profiles (r_member_social_feed is a closed permission).
      parameters:
        - name: accountId
          in: path
          required: true
          description: The ID of the LinkedIn organization account
          schema: { type: string }
        - name: urn
          in: query
          required: true
          description: The LinkedIn post URN
          schema: { type: string }
          example: "urn:li:share:7123456789012345678"
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
          description: Maximum number of reactions to return per page
        - name: cursor
          in: query
          schema: { type: string }
          description: Offset-based pagination start index
      responses:
        '200':
          description: Reactions with reactor profiles
          content:
            application/json:
              schema:
                type: object
                properties:
                  accountId: { type: string }
                  platform: { type: string, example: linkedin }
                  accountType: { type: string, example: organization }
                  username: { type: string }
                  postUrn: { type: string }
                  reactions:
                    type: array
                    items:
                      type: object
                      properties:
                        reactionType:
                          type: string
                          description: LinkedIn reaction enum (LIKE, PRAISE, EMPATHY, INTEREST, APPRECIATION, ENTERTAINMENT)
                        reactionLabel:
                          type: string
                          description: User-friendly label (Like, Celebrate, Love, Insightful, Support, Funny)
                        reactedAt: { type: string, format: date-time }
                        from:
                          type: object
                          properties:
                            urn: { type: string, description: "LinkedIn person or organization URN" }
                            name: { type: string, description: "Reactor's display name" }
                            headline: { type: string, description: "Reactor's headline/job title" }
                            username: { type: string, description: "LinkedIn vanity name" }
                            profilePicture: { type: string, description: "Profile picture URL" }
                            profileUrl: { type: string, description: "Direct link to LinkedIn profile" }
                  pagination:
                    type: object
                    properties:
                      hasMore: { type: boolean }
                      cursor: { type: string, description: "Offset for next page" }
                      total: { type: integer, description: "Total number of reactions (when available)" }
                  lastUpdated: { type: string, format: date-time }
              example:
                accountId: "64abc123def456"
                platform: "linkedin"
                accountType: "organization"
                username: "Acme Corp"
                postUrn: "urn:li:share:7123456789012345678"
                reactions:
                  - reactionType: "LIKE"
                    reactionLabel: "Like"
                    reactedAt: "2026-03-08T12:00:00.000Z"
                    from:
                      urn: "urn:li:person:abc123"
                      name: "Jane Smith"
                      headline: "Product Manager at Acme Corp"
                      username: "janesmith"
                      profilePicture: "https://media.licdn.com/..."
                      profileUrl: "https://www.linkedin.com/in/janesmith"
                pagination:
                  hasMore: true
                  cursor: "25"
                  total: 156
                lastUpdated: "2026-03-08T12:00:00.000Z"
        '400':
          description: Invalid request or platform limitation
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, enum: [missing_urn, invalid_urn, invalid_platform, PLATFORM_LIMITATION] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics add-on required
        '403':
          description: Missing required LinkedIn scope
        '404':
          description: Account or post not found

  /v1/accounts/{accountId}/linkedin-organization:
    put:
      operationId: updateLinkedInOrganization
      tags: [Connect]
      summary: Switch LinkedIn account type
      description: Switch a LinkedIn account between personal profile and organization (company page) posting.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountType]
              properties:
                accountType: { type: string, enum: [personal, organization] }
                selectedOrganization: { type: object }
            example:
              accountType: "organization"
              selectedOrganization:
                id: "12345678"
                name: "Acme Corporation"
                vanityName: "acme-corp"
      responses:
        '200':
          description: Account updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  account: { $ref: '#/components/schemas/SocialAccount' }
              example:
                message: "LinkedIn account type updated successfully"
                account:
                  _id: "64e1f0a9e2b5af0012ab34cd"
                  platform: "linkedin"
                  username: "acme-corp"
                  displayName: "Acme Corporation"
                  isActive: true
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }

  /v1/accounts/{accountId}/linkedin-mentions:
    get:
      operationId: getLinkedInMentions
      tags: [LinkedIn Mentions]
      summary: Resolve LinkedIn mention
      description: |
        Converts a LinkedIn profile or company URL to a URN for @mentions in posts.

        How to use LinkedIn @mentions (2-step workflow):

        1. Call this endpoint with the LinkedIn profile/company URL to get the mention URN and format.
        2. Embed the returned mentionFormat (e.g. @[Vincent Jong](urn:li:person:xxx)) directly in your post's content field.

        Example:
        - Resolve: GET /v1/accounts/{id}/linkedin-mentions?url=linkedin.com/in/vincentjong&displayName=Vincent Jong
        - Returns: mentionFormat: "@[Vincent Jong](urn:li:person:xxx)"
        - Use in post content: "Great talk with @[Vincent Jong](urn:li:person:xxx) today!"

        Important: The mentions array field in POST /v1/posts is stored for reference only and does NOT trigger @mentions on LinkedIn. You must embed the mention format directly in the content text.

        Requirements:
        - Person mentions require the LinkedIn account to be admin of at least one organization. This is a LinkedIn API limitation: the only endpoints that resolve profile URLs to member URNs (vanityUrl, peopleTypeahead) are scoped to organization followers. There is no public LinkedIn API to resolve a vanity URL without organization context.
        - Organization mentions (e.g. @Microsoft) work without this requirement.
        - For person mentions to be clickable, the displayName parameter must exactly match the name shown on their LinkedIn profile.
        - Person mentions DO work when published from personal profiles (the URN just needs to be valid). The limitation is only in the resolution step (URL to URN), not in publishing.
      parameters:
        - name: accountId
          in: path
          required: true
          description: The LinkedIn account ID
          schema: { type: string }
        - name: url
          in: query
          required: true
          description: LinkedIn profile URL, company URL, or vanity name.
          schema: { type: string }
          examples:
            personVanityName:
              value: "miquelpalet"
              summary: Person - just the vanity name
            personFullUrl:
              value: "https://www.linkedin.com/in/miquelpalet"
              summary: Person - full LinkedIn URL
            orgShortUrl:
              value: "company/microsoft"
              summary: Org - short format
            orgFullUrl:
              value: "https://www.linkedin.com/company/microsoft"
              summary: Org - full LinkedIn URL
        - name: displayName
          in: query
          required: false
          description: Exact display name as shown on LinkedIn. Required for person mentions to be clickable. Optional for org mentions.
          schema: { type: string }
          examples:
            personName:
              value: "Miquel Palet"
              summary: Exact name as shown on LinkedIn profile
            orgName:
              value: "Microsoft"
              summary: Company name (optional for orgs)
      responses:
        '200':
          description: URN resolved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  urn:
                    type: string
                    description: The LinkedIn URN (person or organization)
                    example: "urn:li:person:4qj5ox-agD"
                  type:
                    type: string
                    enum: [person, organization]
                    description: The type of entity (person or organization)
                    example: "person"
                  displayName:
                    type: string
                    description: Display name (provided, from API, or derived from vanity URL)
                    example: "Miquel Palet"
                  mentionFormat:
                    type: string
                    description: Ready-to-use mention format for post content
                    example: "@[Miquel Palet](urn:li:person:4qj5ox-agD)"
                  vanityName:
                    type: string
                    description: The vanity name/slug (only for organization mentions)
                    example: "microsoft"
                  warning:
                    type: string
                    description: Warning about clickable mentions (only present for person mentions if displayName was not provided)
                    example: "For clickable person mentions, provide the displayName parameter with the exact name as shown on their LinkedIn profile."
              examples:
                personWithDisplayName:
                  summary: Person mention with displayName (recommended)
                  value:
                    urn: "urn:li:person:4qj5ox-agD"
                    type: "person"
                    displayName: "Miquel Palet"
                    mentionFormat: "@[Miquel Palet](urn:li:person:4qj5ox-agD)"
                personWithoutDisplayName:
                  summary: Person mention without displayName (may not be clickable)
                  value:
                    urn: "urn:li:person:4qj5ox-agD"
                    type: "person"
                    displayName: "Miquelpalet"
                    mentionFormat: "@[Miquelpalet](urn:li:person:4qj5ox-agD)"
                    warning: "For clickable person mentions, provide the displayName parameter with the exact name as shown on their LinkedIn profile."
                organization:
                  summary: Org mention
                  value:
                    urn: "urn:li:organization:1035"
                    type: "organization"
                    displayName: "Microsoft"
                    mentionFormat: "@[Microsoft](urn:li:organization:1035)"
                    vanityName: "microsoft"
        '400':
          description: Invalid request or no organization found (for person mentions)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
              examples:
                missingUrl:
                  value: { error: "url parameter is required" }
                noOrgForPersonMention:
                  value: { error: "No organization found. You need to be an admin of a LinkedIn organization to use person mentions. Organization mentions work without this requirement." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: Person or organization not found
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
              examples:
                memberNotFound:
                  value: { error: "Member not found. Check the LinkedIn URL is correct." }
                orgNotFound:
                  value: { error: "Organization not found. Check the LinkedIn company URL is correct." }

  /v1/accounts/{accountId}/pinterest-boards:
    get:
      operationId: getPinterestBoards
      tags: [Connect]
      summary: List Pinterest boards
      description: Returns the boards available for a connected Pinterest account. Use this to get a board ID when creating a Pinterest post.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Boards list
          content:
            application/json:
              schema:
                type: object
                properties:
                  boards:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        description: { type: string }
                        privacy: { type: string }
              example:
                boards:
                  - id: "123456789012345678"
                    name: "Marketing Ideas"
                    description: "Collection of marketing inspiration"
                    privacy: "PUBLIC"
                  - id: "234567890123456789"
                    name: "Product Photos"
                    description: "Product photography"
                    privacy: "PUBLIC"
        '400': { description: Not a Pinterest account }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
    put:
      operationId: updatePinterestBoards
      tags: [Connect]
      summary: Set default Pinterest board
      description: Sets the default board used when publishing pins for this account.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [defaultBoardId]
              properties:
                defaultBoardId: { type: string }
                defaultBoardName: { type: string }
            example:
              defaultBoardId: "123456789012345678"
              defaultBoardName: "Marketing Ideas"
      responses:
        '200':
          description: Default board set
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  account: { $ref: '#/components/schemas/SocialAccount' }
              example:
                message: "Default Pinterest board updated successfully"
                account:
                  _id: "64e1f0a9e2b5af0012ab34cd"
                  platform: "pinterest"
                  username: "mybrand"
                  displayName: "My Brand"
                  isActive: true
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }

  /v1/accounts/{accountId}/youtube-playlists:
    get:
      operationId: getYoutubePlaylists
      tags: [Connect]
      summary: List YouTube playlists
      description: Returns the playlists available for a connected YouTube account. Use this to get a playlist ID when creating a YouTube post with the playlistId field.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Playlists list
          content:
            application/json:
              schema:
                type: object
                properties:
                  playlists:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        title: { type: string }
                        description: { type: string }
                        privacy: { type: string, enum: [public, private, unlisted] }
                        itemCount: { type: integer }
                        thumbnailUrl: { type: string }
                  defaultPlaylistId:
                    type: string
                    nullable: true
              example:
                playlists:
                  - id: "PLxxxxxxxxxxxxx"
                    title: "Tutorials"
                    description: "Step-by-step video tutorials"
                    privacy: "public"
                    itemCount: 24
                    thumbnailUrl: "https://i.ytimg.com/vi/xxx/mqdefault.jpg"
                  - id: "PLyyyyyyyyyyyyy"
                    title: "Vlogs"
                    description: "Weekly vlogs"
                    privacy: "public"
                    itemCount: 52
                    thumbnailUrl: "https://i.ytimg.com/vi/yyy/mqdefault.jpg"
                defaultPlaylistId: null
        '400': { description: Not a YouTube account }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
    put:
      operationId: updateYoutubeDefaultPlaylist
      tags: [Connect]
      summary: Set default YouTube playlist
      description: Sets the default playlist used when publishing videos for this account. When a post does not specify a playlistId, the default playlist is not automatically used (it is stored for client-side convenience).
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [defaultPlaylistId]
              properties:
                defaultPlaylistId: { type: string }
                defaultPlaylistName: { type: string }
            example:
              defaultPlaylistId: "PLxxxxxxxxxxxxx"
              defaultPlaylistName: "Tutorials"
      responses:
        '200':
          description: Default playlist set
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
              example:
                success: true
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }

  /v1/accounts/{accountId}/gmb-locations:
    get:
      operationId: getGmbLocations
      tags: [Connect]
      summary: List GBP locations
      description: Returns all Google Business Profile locations the connected account has access to, including the currently selected location.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Locations list
          content:
            application/json:
              schema:
                type: object
                properties:
                  locations:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        accountId: { type: string }
                        accountName: { type: string }
                        address: { type: string }
                        category: { type: string }
                        websiteUrl: { type: string }
                  selectedLocationId: { type: string }
                  cached: { type: boolean }
              example:
                locations:
                  - id: "12345678901234567890"
                    name: "My Business Location"
                    accountId: "accounts/123456789"
                    accountName: "My Business Account"
                    address: "123 Main St, San Francisco, CA"
                    category: "Restaurant"
                    websiteUrl: "https://mybusiness.com"
                selectedLocationId: "12345678901234567890"
                cached: true
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
    put:
      operationId: updateGmbLocation
      tags: [Connect]
      summary: Update GBP location
      description: Switch which GBP location is active for a connected account.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [selectedLocationId]
              properties:
                selectedLocationId: { type: string }
            example:
              selectedLocationId: "12345678901234567890"
      responses:
        '200':
          description: Location updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  selectedLocation:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
              example:
                message: "Google Business location updated successfully"
                selectedLocation:
                  id: "12345678901234567890"
                  name: "My Business Location"
        '400': { description: Location not in available locations }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }

  /v1/accounts/{accountId}/reddit-subreddits:
    get:
      operationId: getRedditSubreddits
      tags: [Connect]
      summary: List Reddit subreddits
      description: Returns the subreddits the connected Reddit account can post to. Use this to get a subreddit name when creating a Reddit post.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Subreddits list
          content:
            application/json:
              schema:
                type: object
                properties:
                  subreddits:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: Reddit subreddit ID }
                        name: { type: string, description: Subreddit name without r/ prefix }
                        title: { type: string, description: Subreddit title }
                        url: { type: string, description: Subreddit URL path }
                        over18: { type: boolean, description: Whether the subreddit is NSFW }
                  defaultSubreddit:
                    type: string
                    description: Currently set default subreddit for posting
              example:
                subreddits:
                  - id: "2qh1i"
                    name: "marketing"
                    title: "Marketing"
                    url: "/r/marketing/"
                    over18: false
                  - id: "2qh3l"
                    name: "socialmedia"
                    title: "Social Media"
                    url: "/r/socialmedia/"
                    over18: false
                defaultSubreddit: "marketing"
        '400': { description: Not a Reddit account }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }

    put:
      operationId: updateRedditSubreddits
      tags: [Connect]
      summary: Set default subreddit
      description: Sets the default subreddit used when publishing posts for this Reddit account.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [defaultSubreddit]
              properties:
                defaultSubreddit: { type: string }
            example:
              defaultSubreddit: "marketing"
      responses:
        '200':
          description: Default subreddit set
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
              example:
                success: true
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }

  /v1/accounts/{accountId}/reddit-flairs:
    get:
      operationId: getRedditFlairs
      tags: [Connect]
      summary: List subreddit flairs
      description: Returns available post flairs for a subreddit. Some subreddits require a flair when posting.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
        - name: subreddit
          in: query
          required: true
          schema: { type: string }
          description: Subreddit name (without "r/" prefix) to fetch flairs for
      responses:
        '200':
          description: Flairs list
          content:
            application/json:
              schema:
                type: object
                properties:
                  flairs:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: Flair ID to pass as flairId in platformSpecificData }
                        text: { type: string, description: Flair display text }
                        textColor: { type: string, description: "Text color: 'dark' or 'light'" }
                        backgroundColor: { type: string, description: "Background hex color (e.g. '#ff4500')" }
              example:
                flairs:
                  - id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
                    text: "Discussion"
                    textColor: "dark"
                    backgroundColor: "#edeff1"
                  - id: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
                    text: "News"
                    textColor: "light"
                    backgroundColor: "#ff4500"
        '400': { description: Not a Reddit account or missing subreddit parameter }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }

  /v1/accounts/{accountId}/discord-settings:
    get:
      operationId: getDiscordSettings
      tags: [Discord]
      summary: Get Discord account settings
      description: Returns the current Discord account settings including webhook identity (display name and avatar), connected channel, and guild information.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Discord account settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  account:
                    type: object
                    properties:
                      _id: { type: string }
                      platform: { type: string, example: discord }
                      username: { type: string, description: Channel name }
                      displayName: { type: string, description: "Guild - #channel display name" }
                      profilePicture: { type: string, description: Guild icon URL }
                      channelId: { type: string, description: Connected channel snowflake ID }
                      channelName: { type: string, description: Channel name }
                      channelType: { type: string, description: "Channel type (0 = text, 5 = announcement, 15 = forum)" }
                      guildId: { type: string, description: Guild (server) snowflake ID }
                      webhookUsername: { type: string, nullable: true, description: Custom webhook display name (null = default "Zernio") }
                      webhookAvatarUrl: { type: string, nullable: true, description: Custom webhook avatar URL (null = default bot avatar) }
              example:
                account:
                  _id: "abc123"
                  platform: "discord"
                  username: "announcements"
                  displayName: "My Server - #announcements"
                  profilePicture: "https://cdn.discordapp.com/icons/123/abc.png"
                  channelId: "1234567890123456789"
                  channelName: "announcements"
                  channelType: "0"
                  guildId: "9876543210987654321"
                  webhookUsername: "My Brand"
                  webhookAvatarUrl: "https://example.com/logo.png"
        '400': { description: Not a Discord account }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }

    patch:
      operationId: updateDiscordSettings
      tags: [Discord]
      summary: Update Discord settings
      description: |
        Update Discord account settings. Supports two operations (can be combined):

        1. **Webhook identity** - Set the default display name and avatar that appear as the message author on every post. These are account-level defaults; individual posts can override them via platformSpecificData.webhookUsername / webhookAvatarUrl.

        2. **Switch channel** - Move the connection to a different channel in the same guild. A new webhook is automatically created in the target channel.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                webhookUsername:
                  type: string
                  description: Custom display name for the webhook (1-80 chars). Empty string resets to default ("Zernio"). Cannot contain "clyde" or "discord".
                webhookAvatarUrl:
                  type: string
                  description: Custom avatar URL. Empty string resets to default bot avatar.
                channelId:
                  type: string
                  description: Switch to a different channel in the same guild. Must be a text (0), announcement (5), or forum (15) channel.
            examples:
              identity:
                summary: Update webhook identity
                value:
                  webhookUsername: "My Brand"
                  webhookAvatarUrl: "https://example.com/logo.png"
              channel:
                summary: Switch channel
                value:
                  channelId: "9999999999999999999"
      responses:
        '200':
          description: Settings updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string, example: Discord settings updated }
                  account:
                    type: object
                    properties:
                      _id: { type: string }
                      platform: { type: string }
                      username: { type: string }
                      displayName: { type: string }
                      profilePicture: { type: string }
                      channelId: { type: string }
                      channelName: { type: string }
                      channelType: { type: string }
                      guildId: { type: string }
                      webhookUsername: { type: string, nullable: true }
                      webhookAvatarUrl: { type: string, nullable: true }
        '400': { description: Invalid request (no changes, invalid channel type, or bot cannot access channel) }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Discord account not found }

  /v1/accounts/{accountId}/discord-channels:
    get:
      operationId: getDiscordChannels
      tags: [Discord]
      summary: List Discord guild channels
      description: Returns the text, announcement, and forum channels in the connected Discord guild. Use this to discover available channels when switching the connected channel via PATCH /v1/accounts/{accountId}/discord-settings.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Channel list
          content:
            application/json:
              schema:
                type: object
                properties:
                  channels:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: Channel snowflake ID }
                        name: { type: string, description: Channel name }
                        type: { type: integer, description: "Channel type: 0 (text), 5 (announcement), 15 (forum)" }
              example:
                channels:
                  - id: "1234567890123456789"
                    name: "general"
                    type: 0
                  - id: "2345678901234567890"
                    name: "announcements"
                    type: 5
                  - id: "3456789012345678901"
                    name: "feedback"
                    type: 15
        '400': { description: Not a Discord account or missing guild info }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }

  /v1/queue/slots:
    get:
      operationId: listQueueSlots
      tags: [Queue]
      summary: List schedules
      description: Returns queue schedules for a profile. Use all=true for all queues, or queueId for a specific one. Defaults to the default queue.
      parameters:
        - name: profileId
          in: query
          required: true
          schema: { type: string }
          description: Profile ID to get queues for
        - name: queueId
          in: query
          required: false
          schema: { type: string }
          description: Specific queue ID to retrieve (optional)
        - name: all
          in: query
          required: false
          schema: { type: string, enum: ['true'] }
          description: Set to 'true' to list all queues for the profile
      responses:
        '200':
          description: Queue schedule(s) retrieved
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    description: Single queue response (default behavior)
                    properties:
                      exists: { type: boolean }
                      schedule:
                        $ref: '#/components/schemas/QueueSchedule'
                      nextSlots:
                        type: array
                        items: { type: string, format: date-time }
                  - type: object
                    description: All queues response (when all=true)
                    properties:
                      queues:
                        type: array
                        items:
                          $ref: '#/components/schemas/QueueSchedule'
                      count: { type: integer }
              examples:
                singleQueue:
                  summary: Single queue response
                  value:
                    exists: true
                    schedule:
                      _id: "64f0a1b2c3d4e5f6a7b8c9d1"
                      profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
                      name: "Morning Posts"
                      timezone: "America/New_York"
                      slots:
                        - dayOfWeek: 1
                          time: "09:00"
                        - dayOfWeek: 3
                          time: "09:00"
                        - dayOfWeek: 5
                          time: "10:00"
                      active: true
                      isDefault: true
                    nextSlots:
                      - "2024-11-04T09:00:00-05:00"
                      - "2024-11-06T09:00:00-05:00"
                allQueues:
                  summary: All queues response (all=true)
                  value:
                    queues:
                      - _id: "64f0a1b2c3d4e5f6a7b8c9d1"
                        name: "Morning Posts"
                        isDefault: true
                        timezone: "America/New_York"
                        slots: [{ dayOfWeek: 1, time: "09:00" }]
                        active: true
                      - _id: "64f0a1b2c3d4e5f6a7b8c9d2"
                        name: "Evening Content"
                        isDefault: false
                        timezone: "America/New_York"
                        slots: [{ dayOfWeek: 1, time: "18:00" }]
                        active: true
                    count: 2
        '400': { description: Missing profileId }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Profile not found }
    post:
      operationId: createQueueSlot
      tags: [Queue]
      summary: Create schedule
      description: |
        Create an additional queue for a profile. The first queue created becomes the default.
        Subsequent queues are non-default unless explicitly set.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, name, timezone, slots]
              properties:
                profileId: { type: string, description: Profile ID }
                name: { type: string, description: "Queue name (e.g., Evening Posts)" }
                timezone: { type: string, description: IANA timezone }
                slots:
                  type: array
                  items:
                    $ref: '#/components/schemas/QueueSlot'
                active: { type: boolean, default: true }
            example:
              profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
              name: "Evening Posts"
              timezone: "America/New_York"
              slots:
                - dayOfWeek: 1
                  time: "18:00"
                - dayOfWeek: 3
                  time: "18:00"
                - dayOfWeek: 5
                  time: "18:00"
              active: true
      responses:
        '201':
          description: Queue created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  schedule:
                    $ref: '#/components/schemas/QueueSchedule'
                  nextSlots:
                    type: array
                    items: { type: string, format: date-time }
        '400': { description: Invalid request or validation error }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Profile not found }
    put:
      operationId: updateQueueSlot
      tags: [Queue]
      summary: Update schedule
      description: |
        Create a new queue or update an existing one. Without queueId, creates/updates the default queue. With queueId, updates a specific queue. With setAsDefault=true, makes this queue the default for the profile.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, timezone, slots]
              properties:
                profileId: { type: string }
                queueId: { type: string, description: Queue ID to update (optional) }
                name: { type: string, description: Queue name }
                timezone: { type: string }
                slots:
                  type: array
                  items:
                    $ref: '#/components/schemas/QueueSlot'
                active: { type: boolean, default: true }
                setAsDefault: { type: boolean, description: Make this queue the default }
                reshuffleExisting:
                  type: boolean
                  default: false
                  description: Whether to reschedule existing queued posts to match new slots
            example:
              profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
              queueId: "64f0a1b2c3d4e5f6a7b8c9d1"
              name: "Morning Posts"
              timezone: "America/New_York"
              slots:
                - dayOfWeek: 1
                  time: "09:00"
                - dayOfWeek: 3
                  time: "09:00"
                - dayOfWeek: 5
                  time: "10:00"
              active: true
              setAsDefault: false
      responses:
        '200':
          description: Queue schedule updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  schedule:
                    $ref: '#/components/schemas/QueueSchedule'
                  nextSlots:
                    type: array
                    items: { type: string, format: date-time }
                  reshuffledCount: { type: integer }
              example:
                success: true
                schedule:
                  _id: "64f0a1b2c3d4e5f6a7b8c9d1"
                  profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
                  name: "Morning Posts"
                  timezone: "America/New_York"
                  slots:
                    - dayOfWeek: 1
                      time: "09:00"
                    - dayOfWeek: 3
                      time: "09:00"
                    - dayOfWeek: 5
                      time: "10:00"
                  active: true
                  isDefault: true
                nextSlots:
                  - "2024-11-04T09:00:00-05:00"
                  - "2024-11-06T09:00:00-05:00"
                reshuffledCount: 0
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Profile not found }
    delete:
      operationId: deleteQueueSlot
      tags: [Queue]
      summary: Delete schedule
      description: |
        Delete a queue from a profile. Requires queueId to specify which queue to delete.
        If deleting the default queue, another queue will be promoted to default.
      parameters:
        - name: profileId
          in: query
          required: true
          schema: { type: string }
        - name: queueId
          in: query
          required: true
          schema: { type: string }
          description: Queue ID to delete
      responses:
        '200':
          description: Queue schedule deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  deleted: { type: boolean }
              example:
                success: true
                deleted: true
        '400': { description: Missing profileId or queueId }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/queue/preview:
    get:
      operationId: previewQueue
      tags: [Queue]
      summary: Preview upcoming slots
      description: Returns the next N upcoming queue slot times for a profile as ISO datetime strings.
      parameters:
        - name: profileId
          in: query
          required: true
          schema: { type: string }
        - name: queueId
          in: query
          schema: { type: string }
          description: Filter by specific queue ID. Omit to use the default queue.
        - name: count
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
      responses:
        '200':
          description: Queue slots preview
          content:
            application/json:
              schema:
                type: object
                properties:
                  profileId: { type: string }
                  count: { type: integer }
                  slots:
                    type: array
                    items: { type: string, format: date-time }
              example:
                profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
                count: 10
                slots:
                  - "2024-11-04T09:00:00-05:00"
                  - "2024-11-04T14:00:00-05:00"
                  - "2024-11-06T09:00:00-05:00"
                  - "2024-11-08T10:00:00-05:00"
                  - "2024-11-11T09:00:00-05:00"
                  - "2024-11-11T14:00:00-05:00"
                  - "2024-11-13T09:00:00-05:00"
                  - "2024-11-15T10:00:00-05:00"
                  - "2024-11-18T09:00:00-05:00"
                  - "2024-11-18T14:00:00-05:00"
        '400': { description: Invalid parameters }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Profile or queue schedule not found }
  /v1/queue/next-slot:
    get:
      operationId: getNextQueueSlot
      tags: [Queue]
      summary: Get next available slot
      description: Returns the next available queue slot for preview purposes. To create a queue post, use POST /v1/posts with queuedFromProfile instead of scheduledFor.
      parameters:
        - name: profileId
          in: query
          required: true
          schema: { type: string }
        - name: queueId
          in: query
          required: false
          schema: { type: string }
          description: Specific queue ID (optional, defaults to profile's default queue)
      responses:
        '200':
          description: Next available slot
          content:
            application/json:
              schema:
                type: object
                properties:
                  profileId: { type: string }
                  nextSlot: { type: string, format: date-time }
                  timezone: { type: string }
                  queueId: { type: string, description: Queue ID this slot belongs to }
                  queueName: { type: string, description: Queue name }
              example:
                profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
                nextSlot: "2024-11-04T09:00:00-05:00"
                timezone: "America/New_York"
                queueId: "64f0a1b2c3d4e5f6a7b8c9d1"
                queueName: "Morning Posts"
        '400':
          description: Invalid parameters or inactive queue
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: "Profile or queue schedule not found, or no available slots"
  # ============================================
  # Webhooks API (Multi-Webhook System)
  # ============================================
  /v1/webhooks/settings:
    get:
      operationId: getWebhookSettings
      tags: [Webhooks]
      summary: List webhooks
      description: Retrieve all configured webhooks for the authenticated user. Supports up to 10 webhooks per user.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Webhooks retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  webhooks:
                    type: array
                    items:
                      $ref: '#/components/schemas/Webhook'
              example:
                webhooks:
                  - _id: "507f1f77bcf86cd799439011"
                    name: "My Production Webhook"
                    url: "https://example.com/webhook"
                    events: ["post.published", "post.failed"]
                    isActive: true
                    lastFiredAt: "2024-01-15T10:30:00Z"
                    failureCount: 0
                  - _id: "507f1f77bcf86cd799439012"
                    name: "Slack Notifications"
                    url: "https://hooks.slack.com/services/xxx"
                    events: ["post.failed", "account.disconnected"]
                    isActive: true
                    failureCount: 0
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createWebhookSettings
      tags: [Webhooks]
      summary: Create webhook
      description: |
        Create a new webhook configuration. Maximum 10 webhooks per user.

        `name`, `url` and `events` are required. `url` must be a valid URL and `events` must contain at least one event. Whitespace is trimmed from `url` before validation.

        Webhooks are automatically disabled after 10 consecutive delivery failures.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - name
                - url
                - events
              properties:
                name:
                  type: string
                  description: Webhook name (1-50 characters)
                  minLength: 1
                  maxLength: 50
                url:
                  type: string
                  format: uri
                  description: Webhook endpoint URL (must be a valid URL, whitespace trimmed)
                secret:
                  type: string
                  description: Secret key for HMAC-SHA256 signature verification
                events:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    enum: [post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled, account.connected, account.disconnected, account.ads.initial_sync_completed, message.received, comment.received, review.new, review.updated]
                  description: Events to subscribe to (at least one required)
                isActive:
                  type: boolean
                  default: true
                  description: Enable or disable webhook delivery. Defaults to `true` when omitted.
                customHeaders:
                  type: object
                  additionalProperties:
                    type: string
                  description: Custom headers to include in webhook requests
            examples:
              createWebhook:
                summary: Create webhook with all events
                value:
                  name: "My Production Webhook"
                  url: "https://example.com/webhook"
                  secret: "your-secret-key"
                  events: ["post.scheduled", "post.published", "post.failed", "post.partial", "post.cancelled", "post.recycled", "account.connected", "account.disconnected", "account.ads.initial_sync_completed", "message.received", "comment.received", "review.new", "review.updated"]
                  isActive: true
      responses:
        '200':
          description: Webhook created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  webhook:
                    $ref: '#/components/schemas/Webhook'
        '400': { description: Validation error or maximum webhooks reached }
        '401': { $ref: '#/components/responses/Unauthorized' }
    put:
      operationId: updateWebhookSettings
      tags: [Webhooks]
      summary: Update webhook
      description: |
        Update an existing webhook configuration. All fields except `_id` are optional; only provided fields will be updated.

        When provided, `name` must be 1-50 characters, `url` must be a valid URL, and `events` must contain at least one event. Whitespace is trimmed from `url` before validation.

        Webhooks are automatically disabled after 10 consecutive delivery failures.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - _id
              properties:
                _id:
                  type: string
                  description: Webhook ID to update (required)
                name:
                  type: string
                  description: Webhook name (1-50 characters). Must be non-empty if provided.
                  minLength: 1
                  maxLength: 50
                url:
                  type: string
                  format: uri
                  description: Webhook endpoint URL (must be a valid URL, whitespace trimmed). Must be a valid URL if provided.
                secret:
                  type: string
                  description: Secret key for HMAC-SHA256 signature verification
                events:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    enum: [post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled, account.connected, account.disconnected, account.ads.initial_sync_completed, message.received, comment.received, review.new, review.updated]
                  description: Events to subscribe to. Must contain at least one event if provided.
                isActive:
                  type: boolean
                  description: Enable or disable webhook delivery
                customHeaders:
                  type: object
                  additionalProperties:
                    type: string
                  description: Custom headers to include in webhook requests
            examples:
              updateWebhook:
                summary: Update webhook URL and events
                value:
                  _id: "507f1f77bcf86cd799439011"
                  url: "https://new-example.com/webhook"
                  events: ["post.published", "post.failed"]
              toggleWebhook:
                summary: Enable/disable webhook
                value:
                  _id: "507f1f77bcf86cd799439011"
                  isActive: false
      responses:
        '200':
          description: Webhook updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  webhook:
                    $ref: '#/components/schemas/Webhook'
        '400': { description: Validation error or missing webhook ID }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Webhook not found }
    delete:
      operationId: deleteWebhookSettings
      tags: [Webhooks]
      summary: Delete webhook
      description: Permanently delete a webhook configuration.
      security:
        - bearerAuth: []
      parameters:
        - name: id
          in: query
          required: true
          description: Webhook ID to delete
          schema:
            type: string
      responses:
        '200':
          description: Webhook deleted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '400': { description: Webhook ID required }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/webhooks/test:
    post:
      operationId: testWebhook
      tags: [Webhooks]
      summary: Send test webhook
      description: |
        Send a test webhook to verify your endpoint is configured correctly. The test payload includes event: "webhook.test" to distinguish it from real events.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - webhookId
              properties:
                webhookId:
                  type: string
                  description: ID of the webhook to test
            example:
              webhookId: "507f1f77bcf86cd799439011"
      responses:
        '200':
          description: Test webhook sent successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
              example:
                success: true
                message: "Test webhook sent successfully"
        '400': { description: Webhook ID required }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500':
          description: Test webhook failed to deliver
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
              example:
                success: false
                message: "Test webhook failed"

  /v1/logs:
    get:
      operationId: listLogs
      tags: [Logs]
      summary: List activity logs
      description: |
        Unified logs endpoint. Returns logs for publishing, connections, webhooks, and messaging.
        Filter by type, platform, status, and time range. Logs are retained for 90 days.
      security:
        - bearerAuth: []
      parameters:
        - name: type
          in: query
          description: Log category to query
          schema:
            type: string
            enum: [publishing, connections, webhooks, messaging]
            default: publishing
        - name: status
          in: query
          description: Filter by status
          schema:
            type: string
            enum: [success, failed, pending, skipped, all]
        - name: platform
          in: query
          description: Filter by platform
          schema:
            type: string
            enum: [tiktok, instagram, whatsapp, facebook, youtube, linkedin, twitter, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, all]
        - name: action
          in: query
          description: Filter by action (e.g., post.published, message.sent, account.connected, webhook.delivered)
          schema:
            type: string
        - name: search
          in: query
          description: Free-text search across log fields
          schema:
            type: string
        - name: days
          in: query
          description: Number of days to look back (max 90)
          schema:
            type: integer
            minimum: 1
            maximum: 90
            default: 90
        - name: limit
          in: query
          description: Maximum number of logs to return (max 100)
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: skip
          in: query
          description: Number of logs to skip (for pagination)
          schema:
            type: integer
            minimum: 0
            default: 0
      responses:
        '200':
          description: Logs retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  logs:
                    type: array
                    items:
                      type: object
                      properties:
                        type:
                          type: string
                          description: Log category (publishing, connections, webhooks, messaging)
                        action:
                          type: string
                          description: Specific action (post.published, message.sent, account.connected, etc.)
                        user_id:
                          type: string
                        platform:
                          type: string
                        account_id:
                          type: string
                        status:
                          type: string
                          enum: [success, failed, pending, skipped]
                        status_code:
                          type: integer
                        error_message:
                          type: string
                        error_code:
                          type: string
                        duration_ms:
                          type: integer
                        endpoint:
                          type: string
                          description: The API endpoint that triggered this log
                        request_body:
                          type: string
                          description: Request JSON (truncated to 5KB)
                        response_body:
                          type: string
                          description: Response JSON (truncated to 10KB)
                        created_at:
                          type: string
                          format: date-time
                        metadata:
                          type: string
                          description: Additional context as JSON string
                  pagination:
                    type: object
                    properties:
                      total:
                        type: integer
                      limit:
                        type: integer
                      skip:
                        type: integer
                      pages:
                        type: integer
                      hasMore:
                        type: boolean
        '401': { $ref: '#/components/responses/Unauthorized' }
  # Unified Inbox Endpoints
  /v1/inbox/conversations:
    get:
      operationId: listInboxConversations
      summary: List conversations
      description: |
        Fetch conversations (DMs) from all connected messaging accounts in a single API call. Supports filtering by profile and platform. Results are aggregated and deduplicated.
        Supported platforms: Facebook, Instagram, Twitter/X, Bluesky, Reddit, Telegram.

        Twitter/X limitation: X has replaced traditional DMs with encrypted "X Chat" for many accounts. Messages sent or received through encrypted X Chat are not accessible via X's API (the /2/dm_events endpoint only returns legacy unencrypted DMs). This means some Twitter/X conversations may show only outgoing messages or appear empty. This is an X platform limitation that affects all third-party applications. See X's docs on encrypted messaging for more details.
      tags: [Messages]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: profileId
          in: query
          schema: { type: string }
          description: Filter by profile ID
        - name: platform
          in: query
          schema: { type: string, enum: [facebook, instagram, twitter, bluesky, reddit, telegram] }
          description: Filter by platform
        - name: status
          in: query
          schema: { type: string, enum: [active, archived] }
          description: Filter by conversation status
        - name: sortOrder
          in: query
          schema: { type: string, enum: [asc, desc], default: desc }
          description: Sort order by updated time
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
          description: Maximum number of conversations to return
        - name: cursor
          in: query
          schema: { type: string }
          description: Pagination cursor for next page
        - name: accountId
          in: query
          schema: { type: string }
          description: Filter by specific social account ID
      responses:
        '200':
          description: Aggregated conversations
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        platform: { type: string }
                        accountId: { type: string }
                        accountUsername: { type: string }
                        participantId: { type: string }
                        participantName: { type: string }
                        participantPicture: { type: string, nullable: true }
                        participantVerifiedType:
                          type: string
                          nullable: true
                          enum: [blue, government, business, none]
                          description: X/Twitter verified badge type. Only present for Twitter/X conversations.
                        lastMessage: { type: string }
                        updatedTime: { type: string, format: date-time }
                        status: { type: string, enum: [active, archived] }
                        unreadCount: { type: integer, nullable: true, description: Number of unread messages }
                        url:
                          type: string
                          nullable: true
                          description: Direct link to open the conversation on the platform (if available)
                        instagramProfile:
                          type: object
                          nullable: true
                          description: Instagram profile data for the participant. Only present for Instagram conversations.
                          properties:
                            isFollower:
                              type: boolean
                              nullable: true
                              description: Whether the participant follows your Instagram business account
                            isFollowing:
                              type: boolean
                              nullable: true
                              description: Whether your Instagram business account follows the participant
                            followerCount:
                              type: integer
                              nullable: true
                              description: The participant's follower count on Instagram
                            isVerified:
                              type: boolean
                              nullable: true
                              description: Whether the participant is a verified Instagram user
                            fetchedAt:
                              type: string
                              format: date-time
                              nullable: true
                              description: When this profile data was last fetched from Instagram
                  pagination:
                    type: object
                    properties:
                      hasMore: { type: boolean }
                      nextCursor: { type: string, nullable: true }
                  meta:
                    type: object
                    properties:
                      accountsQueried: { type: integer }
                      accountsFailed: { type: integer }
                      failedAccounts:
                        type: array
                        items:
                          type: object
                          properties:
                            accountId: { type: string }
                            accountUsername: { type: string, nullable: true }
                            platform: { type: string }
                            error: { type: string }
                            code: { type: string, nullable: true, description: Error code if available }
                            retryAfter: { type: integer, nullable: true, description: Seconds to wait before retry (rate limits) }
                      lastUpdated: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
    post:
      operationId: createInboxConversation
      summary: Create conversation
      description: |
        Initiate a new direct message conversation with a specified user. If a conversation already exists with the recipient, the message is added to the existing thread.

        Currently supported platforms: Twitter/X only. Other platforms will return PLATFORM_NOT_SUPPORTED.

        DM eligibility: Before sending, the endpoint checks if the recipient accepts DMs from your account (via the receives_your_dm field). If not, a 422 error with code DM_NOT_ALLOWED is returned. You can skip this check with skipDmCheck: true if you have already verified eligibility.

        X API tier requirement: DM write endpoints require X API Pro tier ($5,000/month) or Enterprise access. This applies to BYOK (Bring Your Own Key) users who provide their own X API credentials.

        Rate limits: 200 requests per 15 minutes, 1,000 per 24 hours per user, 15,000 per 24 hours per app (shared across all DM endpoints).
      tags: [Messages]
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId:
                  type: string
                  description: The social account ID to send from
                participantId:
                  type: string
                  description: Twitter numeric user ID of the recipient. Provide either this or participantUsername.
                participantUsername:
                  type: string
                  description: Twitter username (with or without @) of the recipient. Resolved to a user ID via lookup. Provide either this or participantId.
                message:
                  type: string
                  description: Text content of the message. At least one of message or attachment is required.
                skipDmCheck:
                  type: boolean
                  default: false
                  description: Skip the receives_your_dm eligibility check before sending. Use if you have already verified the recipient accepts DMs.
          multipart/form-data:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId:
                  type: string
                  description: The social account ID to send from
                participantId:
                  type: string
                  description: Twitter numeric user ID of the recipient
                participantUsername:
                  type: string
                  description: Twitter username (with or without @) of the recipient
                message:
                  type: string
                  description: Text content of the message
                attachment:
                  type: string
                  format: binary
                  description: Media attachment (image or video). One attachment per message.
                skipDmCheck:
                  type: string
                  enum: ['true', 'false']
                  default: 'false'
                  description: Skip the DM eligibility check
      responses:
        '201':
          description: Conversation created successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  data:
                    type: object
                    properties:
                      messageId:
                        type: string
                        description: Platform message ID (dm_event_id)
                      conversationId:
                        type: string
                        description: Platform conversation ID (dm_conversation_id)
                      participantId:
                        type: string
                        description: Twitter numeric user ID of the recipient
                      participantName:
                        type: string
                        nullable: true
                        description: Display name of the recipient
                      participantUsername:
                        type: string
                        nullable: true
                        description: Twitter username of the recipient
        '400':
          description: Validation error or platform not supported
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, enum: [PLATFORM_NOT_SUPPORTED] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required or profile limit reached
        '404':
          description: Account or recipient user not found
        '422':
          description: Recipient does not accept DMs from this account
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, enum: [DM_NOT_ALLOWED] }
        '429':
          description: X API rate limit exceeded
  /v1/inbox/conversations/{conversationId}:
    get:
      operationId: getInboxConversation
      summary: Get conversation
      description: Retrieve details and metadata for a specific conversation. Requires accountId query parameter.
      tags: [Messages]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: conversationId
          in: path
          required: true
          schema: { type: string }
          description: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID.
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: The social account ID
      responses:
        '200':
          description: Conversation details
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string }
                      accountId: { type: string }
                      accountUsername: { type: string }
                      platform: { type: string }
                      status: { type: string, enum: [active, archived] }
                      participantName: { type: string }
                      participantId: { type: string }
                      participantVerifiedType:
                        type: string
                        nullable: true
                        enum: [blue, government, business, none]
                        description: X/Twitter verified badge type. Only present for Twitter/X conversations.
                      lastMessage: { type: string }
                      lastMessageAt: { type: string, format: date-time }
                      updatedTime: { type: string, format: date-time }
                      participants:
                        type: array
                        items:
                          type: object
                          properties:
                            id: { type: string }
                            name: { type: string }
                      instagramProfile:
                        type: object
                        nullable: true
                        description: Instagram profile data for the participant. Only present for Instagram conversations.
                        properties:
                          isFollower:
                            type: boolean
                            nullable: true
                            description: Whether the participant follows your Instagram business account
                          isFollowing:
                            type: boolean
                            nullable: true
                            description: Whether your Instagram business account follows the participant
                          followerCount:
                            type: integer
                            nullable: true
                            description: The participant's follower count on Instagram
                          isVerified:
                            type: boolean
                            nullable: true
                            description: Whether the participant is a verified Instagram user
                          fetchedAt:
                            type: string
                            format: date-time
                            nullable: true
                            description: When this profile data was last fetched from Instagram
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
        '404':
          description: Conversation not found
    put:
      operationId: updateInboxConversation
      summary: Update conversation status
      description: Archive or activate a conversation. Requires accountId in request body.
      tags: [Messages]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: conversationId
          in: path
          required: true
          schema: { type: string }
          description: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, status]
              properties:
                accountId: { type: string, description: Social account ID }
                status: { type: string, enum: [active, archived] }
      responses:
        '200':
          description: Conversation updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      id: { type: string }
                      accountId: { type: string }
                      status: { type: string, enum: [active, archived] }
                      platform: { type: string }
                      updatedAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
        '404':
          description: Conversation not found (WhatsApp only; other platforms upsert)

  /v1/inbox/conversations/{conversationId}/messages:
    get:
      operationId: getInboxConversationMessages
      summary: List messages
      description: |
        Fetch messages for a specific conversation, with cursor-based pagination
        and ordering control.

        Pagination: pass `pagination.nextCursor` from a prior response back as
        the `cursor` query param to fetch the next page. The cursor is opaque;
        do not parse or construct it client-side.

        Sort order: defaults to `asc` (oldest first, chat style). For the
        "show me the latest messages" pattern, pass `?sortOrder=desc&limit=N`.
        For Twitter, Facebook and Bluesky, the upstream APIs only return
        newest-first and have no order parameter — sort order is best-effort
        and only reverses items within a single page (pages still walk
        newest→oldest). The response field `sortOrderApplied` tells you what
        was actually applied.

        Reddit threads are paginated client-side because Reddit's API has no
        per-thread cursor. Very long threads may be upstream-truncated by
        Reddit's inbox/sent windows (~100 most-recent items each); this is a
        Reddit platform limitation.

        Twitter/X limitation: X's encrypted "X Chat" messages are not accessible via the API. Conversations where the other participant uses encrypted X Chat may only show your outgoing messages. See the list conversations endpoint for more details.
      tags: [Messages]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: conversationId
          in: path
          required: true
          schema: { type: string }
          description: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID.
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: Social account ID
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 100, default: 100 }
          description: Number of messages to return per page. Default 100, max 100.
        - name: cursor
          in: query
          required: false
          schema: { type: string }
          description: Opaque pagination cursor. Pass `pagination.nextCursor` from a prior response.
        - name: sortOrder
          in: query
          required: false
          schema: { type: string, enum: [asc, desc], default: asc }
          description: |
            Order of returned messages. Default `asc` (oldest first, chat style).
            For Twitter, Facebook and Bluesky, only intra-page ordering is
            affected — pages always walk newest→oldest. See `sortOrderApplied`
            in the response.
      responses:
        '200':
          description: Messages in conversation
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  pagination:
                    type: object
                    properties:
                      hasMore:
                        type: boolean
                        description: Whether more messages are available beyond this page.
                      nextCursor:
                        type: string
                        nullable: true
                        description: Opaque cursor to fetch the next page. `null` on the last page.
                  sortOrderApplied:
                    type: string
                    enum: [asc, desc]
                    description: |
                      Sort order actually applied to the returned page. May
                      differ from the requested `sortOrder` for Twitter,
                      Facebook and Bluesky (always `desc` regardless of request).
                  messages:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        conversationId: { type: string }
                        accountId: { type: string }
                        platform: { type: string }
                        message: { type: string }
                        senderId: { type: string }
                        senderName: { type: string, nullable: true }
                        senderVerifiedType:
                          type: string
                          nullable: true
                          enum: [blue, government, business, none]
                          description: X/Twitter verified badge type. Only present for Twitter/X messages.
                        direction: { type: string, enum: [incoming, outgoing] }
                        createdAt: { type: string, format: date-time }
                        attachments:
                          type: array
                          items:
                            type: object
                            properties:
                              id: { type: string }
                              type: { type: string, enum: [image, video, audio, file, sticker, share] }
                              url: { type: string }
                              filename: { type: string, nullable: true }
                              previewUrl: { type: string, nullable: true }
                        subject: { type: string, nullable: true, description: Reddit message subject }
                        storyReply: { type: boolean, nullable: true, description: Instagram story reply }
                        isStoryMention: { type: boolean, nullable: true, description: Instagram story mention }
                        # ─── Lifecycle state (edits, deletes, delivery) ──────────────
                        # Populated by webhook events from the platforms that support
                        # them. See the support matrix in the Webhooks description
                        # above. Deleted messages retain their original message and
                        # attachments — the Zernio dashboard hides this content, but
                        # it is available here for moderation/compliance use cases.
                        isEdited:
                          type: boolean
                          description: True if the sender has edited this message at least once.
                        editedAt:
                          type: string
                          format: date-time
                          nullable: true
                          description: When the most recent edit happened.
                        editCount:
                          type: integer
                          description: Total number of edits applied.
                        editHistory:
                          type: array
                          description: Every prior version of the message, oldest first.
                          items:
                            type: object
                            properties:
                              text: { type: string, nullable: true }
                              attachments:
                                type: array
                                items:
                                  type: object
                                  properties:
                                    type: { type: string }
                                    url: { type: string }
                                    payload: { type: object }
                              editedAt: { type: string, format: date-time }
                        isDeleted:
                          type: boolean
                          description: True if the sender has deleted (unsent) this message. The original message and attachments fields remain populated.
                        deletedAt:
                          type: string
                          format: date-time
                          nullable: true
                        deliveryStatus:
                          type: string
                          enum: [sent, delivered, read, failed, deleted]
                          nullable: true
                          description: Lifecycle status for outgoing messages. Not all platforms emit every state (see webhook support matrix).
                        deliveredAt:
                          type: string
                          format: date-time
                          nullable: true
                        readAt:
                          type: string
                          format: date-time
                          nullable: true
                        sentAt:
                          type: string
                          format: date-time
                          nullable: true
                          description: Original send time for outgoing messages (used for Messenger watermark queries).
                        deliveryError:
                          type: object
                          nullable: true
                          description: Populated when deliveryStatus === "failed".
                          properties:
                            code: { type: integer }
                            title: { type: string }
                            message: { type: string }
                  lastUpdated: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
    post:
      operationId: sendInboxMessage
      summary: Send message
      description: |
        Send a message in a conversation. Supports text, attachments, quick replies,
        buttons, templates, and message tags. Attachment and interactive message
        support varies by platform.

        WhatsApp rich interactive messages (list, CTA URL, Flow) are available via
        the `interactive` field. Tap events are delivered through the
        `message.received` webhook with WhatsApp-specific `metadata` fields
        (`interactiveType`, `interactiveId`, `flowResponseJson`, `flowResponseData`).
      tags: [Messages]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: conversationId
          in: path
          required: true
          schema: { type: string }
          description: The conversation ID (id field from list conversations endpoint). This is the platform-specific conversation identifier, not an internal database ID.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId: { type: string, description: Social account ID }
                message: { type: string, description: Message text }
                attachmentUrl: { type: string, description: "URL of the attachment to send (image, video, audio, or file). The URL must be publicly accessible. For binary file uploads, use multipart/form-data instead." }
                attachmentType:
                  type: string
                  enum: [image, video, audio, file]
                  description: "Type of attachment. Defaults to file if not specified."
                quickReplies:
                  type: array
                  maxItems: 13
                  description: Quick reply buttons. Mutually exclusive with buttons. Max 13 items.
                  items:
                    type: object
                    required: [title, payload]
                    properties:
                      title: { type: string, maxLength: 20, description: Button label (max 20 chars) }
                      payload: { type: string, description: Payload sent back on tap }
                      imageUrl: { type: string, description: Optional icon URL (Meta only) }
                buttons:
                  type: array
                  maxItems: 3
                  description: Action buttons. Mutually exclusive with quickReplies. Max 3 items.
                  items:
                    type: object
                    required: [type, title]
                    properties:
                      type: { type: string, enum: [url, postback, phone], description: Button type. phone is Facebook only. }
                      title: { type: string, maxLength: 20, description: Button label (max 20 chars) }
                      url: { type: string, description: URL for url-type buttons }
                      payload: { type: string, description: Payload for postback-type buttons }
                      phone: { type: string, description: Phone number for phone-type buttons (Facebook only) }
                template:
                  type: object
                  description: Generic template for carousels (Instagram/Facebook only, ignored on Telegram).
                  properties:
                    type: { type: string, enum: [generic], description: Template type }
                    elements:
                      type: array
                      maxItems: 10
                      items:
                        type: object
                        required: [title]
                        properties:
                          title: { type: string, maxLength: 80, description: Element title (max 80 chars) }
                          subtitle: { type: string, description: Element subtitle }
                          imageUrl: { type: string, description: Element image URL }
                          buttons:
                            type: array
                            maxItems: 3
                            items:
                              type: object
                              properties:
                                type: { type: string, enum: [url, postback] }
                                title: { type: string, maxLength: 20 }
                                url: { type: string }
                                payload: { type: string }
                interactive:
                  type: object
                  description: |
                    WhatsApp-only. Rich interactive payload for list messages, CTA URL
                    buttons, and Flow prompts. When set, takes priority over `buttons`
                    and `quickReplies`. The shape mirrors Meta's Cloud API `interactive`
                    object verbatim, so any payload that works against Meta directly
                    will also work here.

                    Use `buttons` / `quickReplies` for simple button replies
                    (WhatsApp's `interactive.type: "button"`) — the abstraction caps at
                    3 buttons and handles the auto-conversion for you. Use this field
                    only for `list`, `cta_url`, or `flow` messages.

                    Tap events come back via the `message.received` webhook with
                    `metadata.interactiveType` set to `list_reply` or `nfm_reply`.
                  required: [type, body, action]
                  properties:
                    type:
                      type: string
                      enum: [list, cta_url, flow]
                      description: Which interactive layout to render.
                    header:
                      type: object
                      description: Optional header shown above the body.
                      properties:
                        type: { type: string, enum: [text, image, video, document] }
                        text: { type: string, description: Required when header type is text. }
                        image: { type: object, properties: { link: { type: string } } }
                        video: { type: object, properties: { link: { type: string } } }
                        document: { type: object, properties: { link: { type: string } } }
                    body:
                      type: object
                      required: [text]
                      properties:
                        text: { type: string, description: Main body text. }
                    footer:
                      type: object
                      description: Optional footer shown below the action.
                      properties:
                        text: { type: string }
                    action:
                      oneOf:
                        - type: object
                          description: List action. `type` on the parent must be `list`.
                          required: [button, sections]
                          properties:
                            button:
                              type: string
                              description: CTA label that opens the list (max ~20 chars).
                            sections:
                              type: array
                              minItems: 1
                              maxItems: 10
                              description: 1-10 sections. Total rows across all sections cannot exceed 10.
                              items:
                                type: object
                                required: [rows]
                                properties:
                                  title: { type: string, description: Optional section header (max 24 chars). }
                                  rows:
                                    type: array
                                    minItems: 1
                                    maxItems: 10
                                    items:
                                      type: object
                                      required: [id, title]
                                      properties:
                                        id: { type: string, description: Identifier returned in the webhook as metadata.interactiveId (max 200 chars). }
                                        title: { type: string, description: Row label (max 24 chars). }
                                        description: { type: string, description: Optional description below the title (max 72 chars). }
                        - type: object
                          description: CTA URL action. `type` on the parent must be `cta_url`.
                          required: [name, parameters]
                          properties:
                            name: { type: string, enum: [cta_url] }
                            parameters:
                              type: object
                              required: [display_text, url]
                              properties:
                                display_text: { type: string, description: Button label (max 20 chars). }
                                url: { type: string, format: uri, description: Target URL opened when the user taps the button. }
                        - type: object
                          description: Flow action. `type` on the parent must be `flow`.
                          required: [name, parameters]
                          properties:
                            name: { type: string, enum: [flow] }
                            parameters:
                              type: object
                              required: [flow_token, flow_id, flow_cta, flow_action]
                              properties:
                                flow_message_version: { type: string, enum: ['3'], description: Defaults to "3" when omitted. }
                                flow_token: { type: string, description: Opaque token you choose to correlate Flow responses with your own state (max 200 chars). }
                                flow_id: { type: string, description: Published Flow ID from Meta Business Manager. }
                                flow_cta: { type: string, description: Button label that opens the Flow (max 20 chars). }
                                flow_action: { type: string, enum: [navigate, data_exchange], description: "`navigate` sends the user to `flow_action_payload.screen`; `data_exchange` posts data to your Flow endpoint." }
                                flow_action_payload:
                                  type: object
                                  description: Required when flow_action is `navigate`.
                                  properties:
                                    screen: { type: string, description: First screen to show. }
                                    data: { type: object, additionalProperties: true, description: Optional pre-filled data passed to the screen. }
                                mode: { type: string, enum: [draft], description: Set to `draft` to test an unpublished Flow. }
                replyMarkup:
                  type: object
                  description: Telegram-native keyboard markup. Ignored on other platforms.
                  properties:
                    type: { type: string, enum: [inline_keyboard, reply_keyboard], description: Keyboard type }
                    keyboard:
                      type: array
                      description: Array of rows, each row is an array of buttons
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            text: { type: string, description: Button text }
                            callbackData: { type: string, maxLength: 64, description: Callback data (inline_keyboard only, max 64 bytes) }
                            url: { type: string, description: URL to open (inline_keyboard only) }
                    oneTime: { type: boolean, default: true, description: Hide keyboard after use (reply_keyboard only) }
                messagingType:
                  type: string
                  enum: [RESPONSE, UPDATE, MESSAGE_TAG]
                  description: Facebook messaging type. Required when using messageTag.
                messageTag:
                  type: string
                  enum: [CONFIRMED_EVENT_UPDATE, POST_PURCHASE_UPDATE, ACCOUNT_UPDATE, HUMAN_AGENT]
                  description: Facebook message tag for messaging outside 24h window. Requires messagingType MESSAGE_TAG. Instagram only supports HUMAN_AGENT.
                replyTo:
                  type: string
                  description: Platform message ID to quote-reply to. For WhatsApp, pass the wamid (available in message.platformMessageId from webhooks). For Telegram, pass the Telegram message ID.
          multipart/form-data:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId: { type: string, description: Social account ID }
                message: { type: string, description: Message text (optional when sending attachment) }
                attachment:
                  type: string
                  format: binary
                  description: "File attachment (images, videos, documents). Supported formats: JPEG, PNG, GIF, MP4, AAC, WAV. Max 25MB."
                quickReplies:
                  type: string
                  description: JSON string of quick replies array (same schema as application/json body)
                buttons:
                  type: string
                  description: JSON string of buttons array (same schema as application/json body)
                template:
                  type: string
                  description: JSON string of template object (same schema as application/json body)
                replyMarkup:
                  type: string
                  description: JSON string of replyMarkup object (same schema as application/json body)
                messagingType:
                  type: string
                  description: Messaging type (Facebook only). RESPONSE, UPDATE, or MESSAGE_TAG.
                messageTag:
                  type: string
                  description: Message tag (requires messagingType MESSAGE_TAG)
                replyTo:
                  type: string
                  description: Platform message ID to quote-reply to. For WhatsApp, pass the wamid (available in message.platformMessageId from webhooks). For Telegram, pass the Telegram message ID.
      responses:
        '200':
          description: Message sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      messageId: { type: string, description: ID of the sent message (not returned for Reddit) }
                      conversationId: { type: string, nullable: true, description: Twitter conversation ID }
                      sentAt: { type: string, format: date-time, nullable: true, description: Bluesky sent timestamp }
                      message: { type: string, nullable: true, description: Success message (Reddit only) }
        '400':
          description: Bad request (e.g., attachment not supported for platform, validation error)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code:
                    type: string
                    enum: [PLATFORM_LIMITATION]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required

  /v1/inbox/conversations/{conversationId}/messages/{messageId}:
    patch:
      operationId: editInboxMessage
      summary: Edit message
      description: |
        Edit the text and/or reply markup of a previously sent Telegram message.
        Only supported for Telegram. Returns 400 for other platforms.
      tags: [Messages]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: conversationId
          in: path
          required: true
          schema: { type: string }
          description: The conversation ID
        - name: messageId
          in: path
          required: true
          schema: { type: string }
          description: The Telegram message ID to edit
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId: { type: string, description: Social account ID }
                text: { type: string, description: New message text }
                replyMarkup:
                  type: object
                  description: New inline keyboard markup
                  properties:
                    type: { type: string, enum: [inline_keyboard] }
                    keyboard:
                      type: array
                      items:
                        type: array
                        items:
                          type: object
                          properties:
                            text: { type: string }
                            callbackData: { type: string }
                            url: { type: string }
      responses:
        '200':
          description: Message edited
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      messageId: { type: integer }
        '400':
          description: Not supported or invalid request
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
    delete:
      operationId: deleteInboxMessage
      summary: Delete message
      description: |
        Delete a message from a conversation. Platform support varies:
        - Telegram: Full delete (bot's own messages anytime, others if admin)
        - X/Twitter: Full delete (own DM events only)
        - Bluesky: Delete for self only (recipient still sees it)
        - Reddit: Delete from sender's view only
        - Facebook, Instagram, WhatsApp: Not supported (returns 400)
      tags: [Messages]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: conversationId
          in: path
          required: true
          schema: { type: string }
          description: The conversation ID
        - name: messageId
          in: path
          required: true
          schema: { type: string }
          description: The platform message ID to delete
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: Social account ID
      responses:
        '200':
          description: Message deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '400':
          description: Platform does not support deletion or invalid request
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
        '404':
          description: Account or conversation not found

  /v1/inbox/conversations/{conversationId}/typing:
    post:
      operationId: sendTypingIndicator
      summary: Send typing indicator
      description: |
        Show a typing indicator in a conversation. Platform support:
        - Facebook Messenger: Shows "Page is typing..." for 20 seconds
        - Telegram: Shows "Bot is typing..." for 5 seconds
        - All others: Returns 200 but no-op (platform doesn't support it)

        Typing indicators are best-effort. The endpoint always returns 200 even if the platform call fails.
      tags: [Messages]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: conversationId
          in: path
          required: true
          schema: { type: string }
          description: The conversation ID
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId: { type: string, description: Social account ID }
      responses:
        '200':
          description: Typing indicator sent (or no-op on unsupported platforms)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
        '404':
          description: Account or conversation not found

  /v1/inbox/conversations/{conversationId}/messages/{messageId}/reactions:
    post:
      operationId: addMessageReaction
      summary: Add reaction
      description: |
        Add an emoji reaction to a message. Platform support:
        - Telegram: Supports a subset of Unicode emoji reactions
        - WhatsApp: Supports any standard emoji (one reaction per message per sender)
        - All others: Returns 400 (not supported)
      tags: [Messages]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: conversationId
          in: path
          required: true
          schema: { type: string }
          description: The conversation ID
        - name: messageId
          in: path
          required: true
          schema: { type: string }
          description: The platform message ID to react to
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, emoji]
              properties:
                accountId: { type: string, description: Social account ID }
                emoji: { type: string, description: 'Emoji character (e.g. "👍", "❤️")', example: '👍' }
      responses:
        '200':
          description: Reaction added
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '400':
          description: Platform does not support reactions or invalid request
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
        '404':
          description: Account or conversation not found
    delete:
      operationId: removeMessageReaction
      summary: Remove reaction
      description: |
        Remove a reaction from a message. Platform support:
        - Telegram: Send empty reaction array to clear
        - WhatsApp: Send empty emoji to remove
        - All others: Returns 400 (not supported)
      tags: [Messages]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: conversationId
          in: path
          required: true
          schema: { type: string }
          description: The conversation ID
        - name: messageId
          in: path
          required: true
          schema: { type: string }
          description: The platform message ID
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: Social account ID
      responses:
        '200':
          description: Reaction removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '400':
          description: Platform does not support reactions or invalid request
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
        '404':
          description: Account or conversation not found

  /v1/media/upload-direct:
    post:
      operationId: uploadMediaDirect
      summary: Upload media file
      description: |
        Upload a media file using API key authentication and get back a publicly accessible URL.
        The URL can be used as attachmentUrl when sending inbox messages.

        Files are stored in temporary storage and auto-delete after 7 days.
        Maximum file size is 25MB.

        Unlike /v1/media/upload (which uses upload tokens for end-user flows),
        this endpoint uses standard Bearer token authentication for programmatic use.
      tags: [Messages]
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file:
                  type: string
                  format: binary
                  description: The file to upload (max 25MB)
                contentType:
                  type: string
                  description: 'Override MIME type (e.g. "image/jpeg"). Auto-detected from file if not provided.'
      responses:
        '200':
          description: File uploaded successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, description: Publicly accessible URL for the uploaded file }
                  filename: { type: string, description: Generated unique filename }
                  contentType: { type: string, description: MIME type of the file }
                  size: { type: integer, description: File size in bytes }
        '400':
          description: No file provided or file too large
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/accounts/{accountId}/messenger-menu:
    get:
      operationId: getMessengerMenu
      summary: Get FB persistent menu
      description: Get the persistent menu configuration for a Facebook Messenger account.
      tags: [Account Settings]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Persistent menu configuration
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { type: object } }
        '400':
          description: Not a Facebook account
        '401': { $ref: '#/components/responses/Unauthorized' }
    put:
      operationId: setMessengerMenu
      summary: Set FB persistent menu
      description: Set the persistent menu for a Facebook Messenger account. Max 3 top-level items, max 5 nested items.
      tags: [Account Settings]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [persistent_menu]
              properties:
                persistent_menu:
                  type: array
                  description: Persistent menu configuration array (Meta format)
                  items: { type: object }
      responses:
        '200':
          description: Menu set successfully
        '400':
          description: Invalid request
        '401': { $ref: '#/components/responses/Unauthorized' }
    delete:
      operationId: deleteMessengerMenu
      description: Removes the persistent menu from Facebook Messenger conversations for this account.
      summary: Delete FB persistent menu
      tags: [Account Settings]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Menu deleted
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/accounts/{accountId}/instagram-ice-breakers:
    get:
      operationId: getInstagramIceBreakers
      summary: Get IG ice breakers
      description: Get the ice breaker configuration for an Instagram account.
      tags: [Account Settings]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Ice breaker configuration
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { type: array, items: { type: object } }
        '400':
          description: Not an Instagram account
        '401': { $ref: '#/components/responses/Unauthorized' }
    put:
      operationId: setInstagramIceBreakers
      summary: Set IG ice breakers
      description: Set ice breakers for an Instagram account. Max 4 ice breakers, question max 80 chars.
      tags: [Account Settings]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [ice_breakers]
              properties:
                ice_breakers:
                  type: array
                  maxItems: 4
                  items:
                    type: object
                    required: [question, payload]
                    properties:
                      question: { type: string, maxLength: 80 }
                      payload: { type: string }
      responses:
        '200':
          description: Ice breakers set successfully
        '400':
          description: Invalid request
        '401': { $ref: '#/components/responses/Unauthorized' }
    delete:
      operationId: deleteInstagramIceBreakers
      description: Removes the ice breaker questions from an Instagram account's Messenger experience.
      summary: Delete IG ice breakers
      tags: [Account Settings]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Ice breakers deleted
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/accounts/{accountId}/telegram-commands:
    get:
      operationId: getTelegramCommands
      summary: Get TG bot commands
      description: Get the bot commands configuration for a Telegram account.
      tags: [Account Settings]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Bot commands list
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        command: { type: string }
                        description: { type: string }
        '400':
          description: Not a Telegram account
        '401': { $ref: '#/components/responses/Unauthorized' }
    put:
      operationId: setTelegramCommands
      summary: Set TG bot commands
      description: Set bot commands for a Telegram account.
      tags: [Account Settings]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [commands]
              properties:
                commands:
                  type: array
                  items:
                    type: object
                    required: [command, description]
                    properties:
                      command: { type: string, description: Bot command without leading slash }
                      description: { type: string, description: Command description }
      responses:
        '200':
          description: Commands set successfully
        '400':
          description: Invalid request
        '401': { $ref: '#/components/responses/Unauthorized' }
    delete:
      operationId: deleteTelegramCommands
      description: Clears all bot commands configured for a Telegram bot account.
      summary: Delete TG bot commands
      tags: [Account Settings]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Commands deleted
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/inbox/comments:
    get:
      operationId: listInboxComments
      summary: List commented posts
      description: Returns posts with comment counts from all connected accounts. Aggregates data across multiple accounts.
      tags: [Comments]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: profileId
          in: query
          schema: { type: string }
          description: Filter by profile ID
        - name: platform
          in: query
          schema: { type: string, enum: [facebook, instagram, twitter, bluesky, threads, youtube, linkedin, reddit] }
          description: Filter by platform
        - name: minComments
          in: query
          schema: { type: integer, minimum: 0 }
          description: Minimum comment count
        - name: since
          in: query
          schema: { type: string, format: date-time }
          description: Posts created after this date
        - name: sortBy
          in: query
          schema: { type: string, enum: [date, comments], default: date }
          description: Sort field
        - name: sortOrder
          in: query
          schema: { type: string, enum: [asc, desc], default: desc }
          description: Sort order
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: cursor
          in: query
          schema: { type: string }
        - name: accountId
          in: query
          schema: { type: string }
          description: Filter by specific social account ID
      responses:
        '200':
          description: Aggregated posts with comments
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        platform: { type: string }
                        accountId: { type: string }
                        accountUsername: { type: string }
                        content: { type: string }
                        picture: { type: string, nullable: true }
                        permalink: { type: string, nullable: true }
                        createdTime: { type: string, format: date-time }
                        commentCount: { type: integer }
                        likeCount: { type: integer }
                        cid: { type: string, nullable: true, description: Bluesky content identifier }
                        subreddit: { type: string, nullable: true, description: Reddit subreddit name }
                  pagination:
                    type: object
                    properties:
                      hasMore: { type: boolean }
                      nextCursor: { type: string, nullable: true }
                  meta:
                    type: object
                    properties:
                      accountsQueried: { type: integer }
                      accountsFailed: { type: integer }
                      failedAccounts:
                        type: array
                        items:
                          type: object
                          properties:
                            accountId: { type: string }
                            accountUsername: { type: string, nullable: true }
                            platform: { type: string }
                            error: { type: string }
                            code: { type: string, nullable: true, description: Error code if available }
                            retryAfter: { type: integer, nullable: true, description: Seconds to wait before retry (rate limits) }
                      lastUpdated: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required

  /v1/inbox/comments/{postId}:
    get:
      operationId: getInboxPostComments
      summary: Get post comments
      description: Fetch comments for a specific post. Requires accountId query parameter.
      tags: [Comments]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: postId
          in: path
          required: true
          description: Zernio post ID or platform-specific post ID. Zernio IDs are auto-resolved. LinkedIn third-party posts accept full activity URN or numeric ID.
          schema: { type: string }
        - name: accountId
          in: query
          required: true
          schema: { type: string }
        - name: subreddit
          in: query
          schema: { type: string }
          description: (Reddit only) Subreddit name
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
          description: Maximum number of comments to return
        - name: cursor
          in: query
          schema: { type: string }
          description: Pagination cursor
        - name: commentId
          in: query
          schema: { type: string }
          description: (Reddit only) Get replies to a specific comment
      responses:
        '200':
          description: Comments for the post
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  comments:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        message: { type: string }
                        createdTime: { type: string, format: date-time }
                        from:
                          type: object
                          properties:
                            id: { type: string }
                            name: { type: string }
                            username: { type: string }
                            picture: { type: string, nullable: true }
                            isOwner: { type: boolean }
                            verifiedType:
                              type: string
                              nullable: true
                              enum: [blue, government, business, none]
                              description: X/Twitter verified badge type. Only present for Twitter/X comments.
                        likeCount: { type: integer }
                        replyCount: { type: integer }
                        platform: { type: string, description: The platform this comment is from }
                        url:
                          type: string
                          nullable: true
                          description: Direct link to the comment on the platform (if available)
                        replies:
                          type: array
                          items: { type: object }
                        canReply: { type: boolean }
                        canDelete: { type: boolean }
                        canHide: { type: boolean, description: Whether this comment can be hidden (Facebook, Instagram, Threads) }
                        canLike: { type: boolean, description: Whether this comment can be liked (Facebook, Twitter/X, Bluesky, Reddit) }
                        isHidden: { type: boolean, description: Whether the comment is currently hidden }
                        isLiked: { type: boolean, description: Whether the current user has liked this comment }
                        likeUri: { type: string, nullable: true, description: Bluesky like URI for unliking }
                        cid: { type: string, nullable: true, description: Bluesky content identifier }
                        parentId: { type: string, nullable: true, description: Parent comment ID for nested replies }
                        rootUri: { type: string, nullable: true, description: Bluesky root post URI }
                        rootCid: { type: string, nullable: true, description: Bluesky root post CID }
                  pagination:
                    type: object
                    properties:
                      hasMore: { type: boolean }
                      cursor: { type: string, nullable: true }
                  meta:
                    type: object
                    properties:
                      platform: { type: string }
                      postId: { type: string }
                      accountId: { type: string }
                      subreddit: { type: string, nullable: true, description: (Reddit only) Subreddit name }
                      lastUpdated: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
    post:
      operationId: replyToInboxPost
      summary: Reply to comment
      description: Post a reply to a post or specific comment. Requires accountId in request body.
      tags: [Comments]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: postId
          in: path
          required: true
          description: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID.
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, message]
              properties:
                accountId: { type: string }
                message: { type: string }
                commentId: { type: string, description: Reply to specific comment (optional) }
                parentCid: { type: string, description: (Bluesky only) Parent content identifier }
                rootUri: { type: string, description: (Bluesky only) Root post URI }
                rootCid: { type: string, description: (Bluesky only) Root post CID }
      responses:
        '200':
          description: Reply posted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      commentId: { type: string }
                      isReply: { type: boolean }
                      cid: { type: string, nullable: true, description: Bluesky CID }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
    delete:
      operationId: deleteInboxComment
      summary: Delete comment
      description: |
        Delete a comment on a post. Supported by Facebook, Instagram, Bluesky, Reddit, YouTube, and LinkedIn.
        Requires accountId and commentId query parameters.
      tags: [Comments]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: postId
          in: path
          required: true
          description: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID.
          schema: { type: string }
        - name: accountId
          in: query
          required: true
          schema: { type: string }
        - name: commentId
          in: query
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Comment deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  data:
                    type: object
                    properties:
                      message: { type: string }
        '400':
          description: Platform rejected the operation (e.g., comment already deleted, insufficient permissions on the video)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required

  /v1/inbox/comments/{postId}/{commentId}/hide:
    post:
      operationId: hideInboxComment
      summary: Hide comment
      description: |
        Hide a comment on a post. Supported by Facebook, Instagram, Threads, and X/Twitter.
        Hidden comments are only visible to the commenter and page admin.
        For X/Twitter, the reply must belong to a conversation started by the authenticated user.
      tags: [Comments]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
        - name: commentId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId: { type: string, description: The social account ID }
      responses:
        '200':
          description: Comment hidden
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  commentId: { type: string }
                  hidden: { type: boolean }
                  platform: { type: string }
        '400':
          description: Platform does not support hiding comments
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
    delete:
      operationId: unhideInboxComment
      summary: Unhide comment
      description: |
        Unhide a previously hidden comment. Supported by Facebook, Instagram, Threads, and X/Twitter.
      tags: [Comments]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
        - name: commentId
          in: path
          required: true
          schema: { type: string }
        - name: accountId
          in: query
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Comment unhidden
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  commentId: { type: string }
                  hidden: { type: boolean }
                  platform: { type: string }
        '400':
          description: Platform does not support unhiding comments
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required

  /v1/inbox/comments/{postId}/{commentId}/like:
    post:
      operationId: likeInboxComment
      summary: Like comment
      description: |
        Like or upvote a comment on a post. Supported platforms: Facebook, Twitter/X, Bluesky, Reddit.
        For Bluesky, the cid (content identifier) is required in the request body.
      tags: [Comments]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
        - name: commentId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId: { type: string, description: The social account ID }
                cid: { type: string, description: (Bluesky only) Content identifier for the comment }
      responses:
        '200':
          description: Comment liked
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  commentId: { type: string }
                  liked: { type: boolean }
                  likeUri: { type: string, description: (Bluesky only) URI to use for unliking }
                  platform: { type: string }
        '400':
          description: Platform does not support liking comments
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
    delete:
      operationId: unlikeInboxComment
      summary: Unlike comment
      description: |
        Remove a like from a comment. Supported platforms: Facebook, Twitter/X, Bluesky, Reddit.
        For Bluesky, the likeUri query parameter is required.
      tags: [Comments]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
        - name: commentId
          in: path
          required: true
          schema: { type: string }
        - name: accountId
          in: query
          required: true
          schema: { type: string }
        - name: likeUri
          in: query
          schema: { type: string }
          description: (Bluesky only) The like URI returned when liking
      responses:
        '200':
          description: Comment unliked
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  commentId: { type: string }
                  liked: { type: boolean }
                  platform: { type: string }
        '400':
          description: Platform does not support unliking comments
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required

  /v1/inbox/comments/{postId}/{commentId}/private-reply:
    post:
      operationId: sendPrivateReplyToComment
      summary: Send private reply
      description: Send a private message to the author of a comment. Supported on Instagram and Facebook only. One reply per comment, must be sent within 7 days, text only.
      tags: [Comments]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
          description: The media/post ID (Instagram media ID or Facebook post ID)
        - name: commentId
          in: path
          required: true
          schema: { type: string }
          description: The comment ID to send a private reply to
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, message]
              properties:
                accountId:
                  type: string
                  description: The social account ID (Instagram or Facebook)
                message:
                  type: string
                  description: The message text to send as a private DM
            example:
              accountId: "507f1f77bcf86cd799439011"
              message: "Hi! Thanks for your comment. I wanted to reach out privately to help with your question."
      responses:
        '200':
          description: Private reply sent successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  status:
                    type: string
                    example: success
                  messageId:
                    type: string
                    description: The ID of the sent message
                  commentId:
                    type: string
                    description: The comment ID that was replied to
                  platform:
                    type: string
                    enum: [instagram, facebook]
                    example: instagram
        '400':
          description: Bad request
          content:
            application/json:
              schema:
                type: object
                properties:
                  error:
                    type: string
                  code:
                    type: string
                    enum: [PLATFORM_LIMITATION]
              examples:
                platformNotSupported:
                  summary: Platform not supported
                  value:
                    error: "Private replies to comments are only supported on Instagram and Facebook."
                    code: "PLATFORM_LIMITATION"
                alreadyReplied:
                  summary: Already sent a private reply
                  value:
                    error: "A private reply has already been sent to this comment, or the 7-day reply window has expired. Only one private reply per comment is allowed within 7 days."
                commentTooOld:
                  summary: Comment older than 7 days
                  value:
                    error: "The comment is older than 7 days. Private replies can only be sent within 7 days of the comment being posted."
                missingMessage:
                  summary: Missing message
                  value:
                    error: "message is required and must be a non-empty string"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
        '404':
          description: Account not found

  /v1/twitter/retweet:
    post:
      operationId: retweetPost
      summary: Retweet a post
      description: |
        Retweet (repost) a tweet by ID.
        Rate limit: 50 requests per 15-min window. Shares the 300/3hr creation limit with tweet creation.
      tags: [Twitter Engagement]
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, tweetId]
              properties:
                accountId: { type: string, description: The social account ID }
                tweetId: { type: string, description: The ID of the tweet to retweet }
      responses:
        '200':
          description: Tweet retweeted
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: success }
                  tweetId: { type: string }
                  retweeted: { type: boolean }
                  platform: { type: string, example: twitter }
        '400': { description: Bad request or platform limitation }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
    delete:
      operationId: undoRetweet
      summary: Undo retweet
      description: |
        Undo a retweet (un-repost a tweet).
      tags: [Twitter Engagement]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
        - name: tweetId
          in: query
          required: true
          schema: { type: string }
          description: The ID of the original tweet to un-retweet
      responses:
        '200':
          description: Retweet undone
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: success }
                  tweetId: { type: string }
                  retweeted: { type: boolean, example: false }
                  platform: { type: string, example: twitter }
        '400': { description: Bad request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }

  /v1/twitter/bookmark:
    post:
      operationId: bookmarkPost
      summary: Bookmark a tweet
      description: |
        Bookmark a tweet by ID.
        Requires the bookmark.write OAuth scope.
        Rate limit: 50 requests per 15-min window.
      tags: [Twitter Engagement]
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, tweetId]
              properties:
                accountId: { type: string, description: The social account ID }
                tweetId: { type: string, description: The ID of the tweet to bookmark }
      responses:
        '200':
          description: Tweet bookmarked
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: success }
                  tweetId: { type: string }
                  bookmarked: { type: boolean }
                  platform: { type: string, example: twitter }
        '400': { description: Bad request or platform limitation }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
    delete:
      operationId: removeBookmark
      summary: Remove bookmark
      description: |
        Remove a bookmark from a tweet.
      tags: [Twitter Engagement]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
        - name: tweetId
          in: query
          required: true
          schema: { type: string }
          description: The ID of the tweet to unbookmark
      responses:
        '200':
          description: Bookmark removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: success }
                  tweetId: { type: string }
                  bookmarked: { type: boolean, example: false }
                  platform: { type: string, example: twitter }
        '400': { description: Bad request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }

  /v1/twitter/follow:
    post:
      operationId: followUser
      summary: Follow a user
      description: |
        Follow a user on X/Twitter.
        Requires the follows.write OAuth scope.
        For protected accounts, a follow request is sent instead (pending_follow will be true).
      tags: [Twitter Engagement]
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, targetUserId]
              properties:
                accountId: { type: string, description: The social account ID }
                targetUserId: { type: string, description: The Twitter ID of the user to follow }
      responses:
        '200':
          description: User followed or follow request sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: success }
                  targetUserId: { type: string }
                  following: { type: boolean }
                  pending_follow: { type: boolean, description: True if the target account is protected and a follow request was sent }
                  platform: { type: string, example: twitter }
        '400': { description: Bad request or platform limitation }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
    delete:
      operationId: unfollowUser
      summary: Unfollow a user
      description: |
        Unfollow a user on X/Twitter.
      tags: [Twitter Engagement]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
        - name: targetUserId
          in: query
          required: true
          schema: { type: string }
          description: The Twitter ID of the user to unfollow
      responses:
        '200':
          description: User unfollowed
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: success }
                  targetUserId: { type: string }
                  following: { type: boolean, example: false }
                  platform: { type: string, example: twitter }
        '400': { description: Bad request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }

  /v1/inbox/reviews:
    get:
      operationId: listInboxReviews
      summary: List reviews
      description: |
        Fetch reviews from all connected Facebook Pages and Google Business accounts. Aggregates data with filtering and sorting options.
        Supported platforms: Facebook, Google Business.
      tags: [Reviews]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: profileId
          in: query
          schema: { type: string }
        - name: platform
          in: query
          schema: { type: string, enum: [facebook, googlebusiness] }
        - name: minRating
          in: query
          schema: { type: integer, minimum: 1, maximum: 5 }
        - name: maxRating
          in: query
          schema: { type: integer, minimum: 1, maximum: 5 }
        - name: hasReply
          in: query
          schema: { type: boolean }
          description: Filter by reply status
        - name: sortBy
          in: query
          schema: { type: string, enum: [date, rating], default: date }
        - name: sortOrder
          in: query
          schema: { type: string, enum: [asc, desc], default: desc }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 25 }
        - name: cursor
          in: query
          schema: { type: string }
        - name: accountId
          in: query
          schema: { type: string }
          description: Filter by specific social account ID
      responses:
        '200':
          description: Aggregated reviews
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        platform: { type: string }
                        accountId: { type: string }
                        accountUsername: { type: string }
                        reviewer:
                          type: object
                          properties:
                            id: { type: string, nullable: true }
                            name: { type: string }
                            profileImage: { type: string, nullable: true }
                        rating: { type: integer }
                        text: { type: string }
                        created: { type: string, format: date-time }
                        hasReply: { type: boolean }
                        reply:
                          type: object
                          nullable: true
                          properties:
                            id: { type: string }
                            text: { type: string }
                            created: { type: string, format: date-time }
                        reviewUrl: { type: string, nullable: true }
                  pagination:
                    type: object
                    properties:
                      hasMore: { type: boolean }
                      nextCursor: { type: string, nullable: true }
                  meta:
                    type: object
                    properties:
                      accountsQueried: { type: integer }
                      accountsFailed: { type: integer }
                      failedAccounts:
                        type: array
                        items:
                          type: object
                          properties:
                            accountId: { type: string }
                            accountUsername: { type: string, nullable: true }
                            platform: { type: string }
                            error: { type: string }
                            code: { type: string, nullable: true, description: Error code if available }
                            retryAfter: { type: integer, nullable: true, description: Seconds to wait before retry (rate limits) }
                      lastUpdated: { type: string, format: date-time }
                  summary:
                    type: object
                    properties:
                      totalReviews: { type: integer }
                      averageRating: { type: number, nullable: true }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required

  /v1/inbox/reviews/{reviewId}/reply:
    post:
      operationId: replyToInboxReview
      summary: Reply to review
      description: Post a reply to a review. Requires accountId in request body.
      tags: [Reviews]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: reviewId
          in: path
          required: true
          schema: { type: string }
          description: Review ID (URL-encoded for Google Business)
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, message]
              properties:
                accountId: { type: string }
                message: { type: string }
      responses:
        '200':
          description: Reply posted
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  reply:
                    type: object
                    properties:
                      id: { type: string }
                      text: { type: string }
                      created: { type: string, format: date-time }
                  platform: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
    delete:
      operationId: deleteInboxReviewReply
      summary: Delete review reply
      description: Delete a reply to a review (Google Business only). Requires accountId in request body.
      tags: [Reviews]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: reviewId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId: { type: string }
      responses:
        '200':
          description: Reply deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  message: { type: string }
                  platform: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
  # ──────────────────────────────────────────────────────────────────────────
  # WHATSAPP-SPECIFIC ENDPOINTS
  # Templates, business profile, phone numbers: ACTIVE (no cross-platform equivalent)
  # ──────────────────────────────────────────────────────────────────────────

  # ──────────────────────────────────────────────────────────────────────────
  # TEMPLATES
  # ──────────────────────────────────────────────────────────────────────────

  /v1/whatsapp/templates:
    get:
      operationId: getWhatsAppTemplates
      tags: [WhatsApp]
      summary: List templates
      description: |
        List all message templates for the WhatsApp Business Account (WABA) associated with the given account.
        Templates are fetched directly from the WhatsApp Cloud API.
      security:
        - bearerAuth: []
      parameters:
        - name: accountId
          in: query
          required: true
          description: WhatsApp social account ID
          schema:
            type: string
      responses:
        '200':
          description: Templates retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  templates:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: WhatsApp template ID }
                        name: { type: string }
                        status: { type: string, enum: [APPROVED, PENDING, REJECTED] }
                        category: { type: string, enum: [AUTHENTICATION, MARKETING, UTILITY] }
                        language: { type: string }
                        components:
                          type: array
                          items:
                            type: object
        '400': { description: accountId is required or WABA ID not found }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }
    post:
      operationId: createWhatsAppTemplate
      tags: [WhatsApp]
      summary: Create template
      description: |
        Create a new message template. Supports two modes:

        Custom template: Provide components with your own content. Submitted to Meta for review (can take up to 24h).

        Library template: Provide library_template_name instead of components to use a pre-built template
        from Meta's template library. Library templates are pre-approved (no review wait). You can optionally
        customize parameters and buttons via library_template_body_inputs and library_template_button_inputs.

        Browse available library templates at: https://business.facebook.com/wa/manage/message-templates/
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - accountId
                - name
                - category
                - language
              properties:
                accountId:
                  type: string
                  description: WhatsApp social account ID
                name:
                  type: string
                  pattern: "^[a-z][a-z0-9_]*$"
                  description: Template name (lowercase, letters/numbers/underscores, must start with a letter)
                category:
                  type: string
                  enum: [AUTHENTICATION, MARKETING, UTILITY]
                  description: Template category
                language:
                  type: string
                  description: Template language code (e.g., en_US)
                components:
                  type: array
                  description: "Template components (header, body, footer, buttons). Required for custom templates, omit when using library_template_name."
                  minItems: 1
                  items:
                    $ref: '#/components/schemas/WhatsAppTemplateComponent'
                library_template_name:
                  type: string
                  description: |
                    Name of a pre-built template from Meta's template library (e.g., "appointment_reminder",
                    "auto_pay_reminder_1", "address_update"). When provided, the template is pre-approved
                    by Meta with no review wait. Omit components when using this field.
                library_template_body_inputs:
                  type: object
                  description: |
                    Optional body customizations for library templates. Available options depend on the
                    template (e.g., add_contact_number, add_learn_more_link, add_security_recommendation,
                    add_track_package_link, code_expiration_minutes).
                library_template_button_inputs:
                  type: array
                  description: |
                    Optional button customizations for library templates. Each item specifies button type
                    and configuration (e.g., URL, phone number, quick reply).
                  items:
                    type: object
                    properties:
                      type:
                        type: string
                        enum: [quick_reply, url, phone_number]
                      url:
                        type: object
                        properties:
                          base_url: { type: string }
                      phone_number:
                        type: string
            examples:
              custom:
                summary: Custom template (requires review)
                value:
                  accountId: "507f1f77bcf86cd799439011"
                  name: "order_confirmation"
                  category: "UTILITY"
                  language: "en_US"
                  components:
                    - type: "header"
                      format: "image"
                      example:
                        header_handle: ["https://example.com/header.jpg"]
                    - type: "body"
                      text: "Your order {{1}} has been confirmed. Expected delivery: {{2}}"
                      example:
                        body_text: [["ORD-12345", "March 31"]]
                    - type: "footer"
                      text: "Thank you for your purchase"
                    - type: "buttons"
                      buttons:
                        - type: "quick_reply"
                          text: "Track Order"
              library:
                summary: Library template (pre-approved, no review)
                value:
                  accountId: "507f1f77bcf86cd799439011"
                  name: "my_appointment_reminder"
                  category: "UTILITY"
                  language: "en_US"
                  library_template_name: "appointment_reminder"
                  library_template_button_inputs:
                    - type: "url"
                      url:
                        base_url: "https://myapp.com/appointments/{{1}}"
      responses:
        '200':
          description: Template created (pre-approved for library templates, pending review for custom)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  template:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      status: { type: string, description: "APPROVED for library templates, PENDING for custom" }
                      category: { type: string }
                      language: { type: string }
        '400': { description: Validation error (invalid name format, missing fields, invalid category) }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }

  /v1/whatsapp/templates/{templateName}:
    get:
      operationId: getWhatsAppTemplate
      tags: [WhatsApp]
      summary: Get template
      description: |
        Retrieve a single message template by name.
      security:
        - bearerAuth: []
      parameters:
        - name: templateName
          in: path
          required: true
          description: Template name
          schema:
            type: string
        - name: accountId
          in: query
          required: true
          description: WhatsApp social account ID
          schema:
            type: string
      responses:
        '200':
          description: Template retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  template:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      status: { type: string }
                      category: { type: string }
                      language: { type: string }
                      components:
                        type: array
                        items:
                          type: object
        '400': { description: accountId is required }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updateWhatsAppTemplate
      tags: [WhatsApp]
      summary: Update template
      description: |
        Update a message template's components. Only certain fields can be updated depending on
        the template's current approval state. Approved templates can only have components updated.
      security:
        - bearerAuth: []
      parameters:
        - name: templateName
          in: path
          required: true
          description: Template name
          schema:
            type: string
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - accountId
                - components
              properties:
                accountId:
                  type: string
                  description: WhatsApp social account ID
                components:
                  type: array
                  description: Updated template components
                  minItems: 1
                  items:
                    $ref: '#/components/schemas/WhatsAppTemplateComponent'
            example:
              accountId: "507f1f77bcf86cd799439011"
              components:
                - type: "body"
                  text: "Updated: Your order {{1}} is confirmed. Delivery by {{2}}"
                  example:
                    body_text: [["ORD-12345", "April 1"]]
                - type: "buttons"
                  buttons:
                    - type: "quick_reply"
                      text: "Track Order"
      responses:
        '200':
          description: Template updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  template:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      status: { type: string }
        '400': { description: Validation error (missing fields) }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: deleteWhatsAppTemplate
      tags: [WhatsApp]
      summary: Delete template
      description: |
        Permanently delete a message template by name.
      security:
        - bearerAuth: []
      parameters:
        - name: templateName
          in: path
          required: true
          description: Template name
          schema:
            type: string
        - name: accountId
          in: query
          required: true
          description: WhatsApp social account ID
          schema:
            type: string
      responses:
        '200':
          description: Template deleted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
              example:
                success: true
                message: "Template \"order_confirmation\" deleted successfully"
        '400': { description: accountId or template name is required }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ──────────────────────────────────────────────────────────────────────────
  # BUSINESS PROFILE
  # ──────────────────────────────────────────────────────────────────────────

  /v1/whatsapp/business-profile:
    get:
      operationId: getWhatsAppBusinessProfile
      tags: [WhatsApp]
      summary: Get business profile
      description: |
        Retrieve the WhatsApp Business profile for the account (about, address, description, email, websites, etc.).
      security:
        - bearerAuth: []
      parameters:
        - name: accountId
          in: query
          required: true
          description: WhatsApp social account ID
          schema:
            type: string
      responses:
        '200':
          description: Business profile retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  businessProfile:
                    type: object
                    properties:
                      about: { type: string, description: Short description (max 139 chars) }
                      address: { type: string }
                      description: { type: string, description: Full description (max 512 chars) }
                      email: { type: string }
                      profilePictureUrl: { type: string, format: uri }
                      websites:
                        type: array
                        items: { type: string }
                        maxItems: 2
                      vertical: { type: string, description: Business category }
        '400': { description: accountId is required or phone number ID not found }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }
    post:
      operationId: updateWhatsAppBusinessProfile
      tags: [WhatsApp]
      summary: Update business profile
      description: |
        Update the WhatsApp Business profile. All fields are optional; only provided fields will be updated.
        Constraints: about max 139 chars, description max 512 chars, max 2 websites.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - accountId
              properties:
                accountId:
                  type: string
                  description: WhatsApp social account ID
                about:
                  type: string
                  maxLength: 139
                  description: Short business description (max 139 characters)
                address:
                  type: string
                  description: Business address
                description:
                  type: string
                  maxLength: 512
                  description: Full business description (max 512 characters)
                email:
                  type: string
                  format: email
                  description: Business email
                websites:
                  type: array
                  maxItems: 2
                  items: { type: string, format: uri }
                  description: Business websites (max 2)
                vertical:
                  type: string
                  description: Business category (e.g., RETAIL, ENTERTAINMENT, etc.)
                profilePictureHandle:
                  type: string
                  description: Handle from resumable upload for profile picture
            example:
              accountId: "507f1f77bcf86cd799439011"
              about: "We help businesses grow"
              description: "Premium business solutions for startups and enterprises"
              email: "hello@example.com"
              websites: ["https://example.com"]
      responses:
        '200':
          description: Business profile updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
              example:
                success: true
                message: "Business profile updated successfully"
        '400': { description: Validation error (field too long, too many websites, etc.) }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }

  /v1/whatsapp/business-profile/photo:
    post:
      operationId: uploadWhatsAppProfilePhoto
      tags: [WhatsApp]
      summary: Upload profile picture
      description: |
        Upload a new profile picture for the WhatsApp Business Profile.
        Uses Meta's resumable upload API under the hood: creates an upload session,
        uploads the image bytes, then updates the business profile with the resulting handle.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [accountId, file]
              properties:
                accountId:
                  type: string
                  description: WhatsApp social account ID
                file:
                  type: string
                  format: binary
                  description: Image file (JPEG or PNG, max 5MB, recommended 640x640)
      responses:
        '200':
          description: Profile picture updated successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
        '400': { description: Invalid file type, file too large, or missing parameters }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }

  /v1/whatsapp/business-profile/display-name:
    get:
      operationId: getWhatsAppDisplayName
      tags: [WhatsApp]
      summary: Get display name status
      description: |
        Fetch the current display name and its Meta review status for a WhatsApp Business account.
        Display name changes require Meta approval and can take 1-3 business days.
      security:
        - bearerAuth: []
      parameters:
        - name: accountId
          in: query
          required: true
          description: WhatsApp social account ID
          schema:
            type: string
      responses:
        '200':
          description: Display name info retrieved
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  displayName:
                    type: object
                    properties:
                      name: { type: string, description: Current verified display name }
                      status:
                        type: string
                        enum: [APPROVED, PENDING_REVIEW, DECLINED, NONE]
                        description: Meta review status for the display name
                      phoneNumber: { type: string, description: Display phone number }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }
    post:
      operationId: updateWhatsAppDisplayName
      tags: [WhatsApp]
      summary: Request display name change
      description: |
        Submit a display name change request for the WhatsApp Business account.
        The new name must follow WhatsApp naming guidelines (3-512 characters, must represent your business).
        Changes require Meta review and approval, which typically takes 1-3 business days.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, displayName]
              properties:
                accountId:
                  type: string
                  description: WhatsApp social account ID
                displayName:
                  type: string
                  minLength: 3
                  maxLength: 512
                  description: New display name (must follow WhatsApp naming guidelines)
            example:
              accountId: "507f1f77bcf86cd799439011"
              displayName: "My Business Name"
      responses:
        '200':
          description: Display name change submitted for review
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
                  displayName:
                    type: object
                    properties:
                      name: { type: string }
                      status: { type: string, enum: [PENDING_REVIEW] }
        '400': { description: Invalid display name (too short, too long, or missing) }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }

  # ──────────────────────────────────────────────────────────────────────────
  # PHONE NUMBERS
  # ──────────────────────────────────────────────────────────────────────────

  /v1/whatsapp/phone-numbers:
    get:
      operationId: getWhatsAppPhoneNumbers
      tags: [WhatsApp Phone Numbers]
      summary: List phone numbers
      description: |
        List all WhatsApp phone numbers purchased by the authenticated user.
        By default, released numbers are excluded.
      security:
        - bearerAuth: []
      parameters:
        - name: status
          in: query
          required: false
          description: Filter by status (by default excludes released numbers)
          schema:
            type: string
            enum: [provisioning, active, suspended, releasing, released]
        - name: profileId
          in: query
          required: false
          description: Filter by profile
          schema:
            type: string
      responses:
        '200':
          description: Phone numbers retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  numbers:
                    type: array
                    items:
                      type: object
                      properties:
                        _id: { type: string }
                        phoneNumber: { type: string }
                        country: { type: string }
                        status: { type: string, enum: [pending_payment, provisioning, active, suspended, releasing, released] }
                        profileId: { type: object }
                        provisionedAt: { type: string, format: date-time }
                        metaPreverifiedId: { type: string }
                        metaVerificationStatus: { type: string }
                        createdAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/phone-numbers/purchase:
    post:
      operationId: purchaseWhatsAppPhoneNumber
      tags: [WhatsApp Phone Numbers]
      summary: Purchase phone number
      description: |
        Initiate purchasing a WhatsApp phone number. Payment-first flow: the user does not pick
        a specific number. The system either creates a Stripe Checkout Session (first number)
        or increments the existing subscription quantity and provisions inline (subsequent numbers).

        Requires a paid plan. The maximum number of phone numbers is determined by the user's plan.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required:
                - profileId
              properties:
                profileId:
                  type: string
                  description: Profile to associate the number with
            example:
              profileId: "507f1f77bcf86cd799439011"
      responses:
        '200':
          description: |
            Either a checkout URL (first number) or the provisioned phone number (subsequent numbers).
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    description: Checkout session created (first number)
                    properties:
                      message: { type: string }
                      checkoutUrl: { type: string, format: uri }
                  - type: object
                    description: Phone number provisioned inline (subsequent numbers)
                    properties:
                      message: { type: string }
                      phoneNumber:
                        type: object
                        properties:
                          id: { type: string }
                          phoneNumber: { type: string }
                          status: { type: string }
                          country: { type: string }
                          provisionedAt: { type: string, format: date-time }
                          metaPreverifiedId: { type: string }
                          metaVerificationStatus: { type: string }
        '400': { description: Plan limit reached or profileId required }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: A paid plan is required }

  /v1/whatsapp/phone-numbers/{phoneNumberId}:
    get:
      operationId: getWhatsAppPhoneNumber
      tags: [WhatsApp Phone Numbers]
      summary: Get phone number
      description: |
        Retrieve the current status of a purchased phone number. Used to poll for
        Meta pre-verification completion after purchase.
      security:
        - bearerAuth: []
      parameters:
        - name: phoneNumberId
          in: path
          required: true
          description: Phone number record ID
          schema:
            type: string
      responses:
        '200':
          description: Phone number retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  phoneNumber:
                    type: object
                    properties:
                      id: { type: string }
                      phoneNumber: { type: string }
                      status: { type: string, enum: [pending_payment, provisioning, active, suspended, releasing, released] }
                      country: { type: string }
                      metaPreverifiedId: { type: string }
                      metaVerificationStatus: { type: string }
                      provisionedAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: releaseWhatsAppPhoneNumber
      tags: [WhatsApp Phone Numbers]
      summary: Release phone number
      description: |
        Release a purchased phone number. This will:
        1. Disconnect any linked WhatsApp social account
        2. Decrement the Stripe subscription quantity (or cancel if last number)
        3. Release the number from Telnyx
        4. Mark the number as released
      security:
        - bearerAuth: []
      parameters:
        - name: phoneNumberId
          in: path
          required: true
          description: Phone number record ID
          schema:
            type: string
      responses:
        '200':
          description: Phone number released successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  phoneNumber:
                    type: object
                    properties:
                      id: { type: string }
                      phoneNumber: { type: string }
                      status: { type: string, description: "\"released\"" }
                      releasedAt: { type: string, format: date-time }
        '400': { description: Phone number is already released or being released }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ─── WhatsApp Group Chats (platform groups, not contact groups) ──

  /v1/whatsapp/wa-groups:
    get:
      operationId: listWhatsAppGroupChats
      tags: [WhatsApp]
      summary: List active groups
      description: |
        List active WhatsApp group chats for a business phone number.
        These are actual WhatsApp group conversations on the platform.

        Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
        - { name: limit, in: query, schema: { type: integer, default: 25, maximum: 1024 }, description: Max groups to return }
        - { name: after, in: query, schema: { type: string }, description: Pagination cursor }
      responses:
        '200':
          description: List of active groups
          content:
            application/json:
              schema:
                type: object
                properties:
                  groups:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: Group ID }
                        subject: { type: string, description: Group name }
                        createdAt: { type: string, description: Group creation timestamp }
                  paging:
                    type: object
                    properties:
                      cursors:
                        type: object
                        properties:
                          after: { type: string }
                          before: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createWhatsAppGroupChat
      tags: [WhatsApp]
      summary: Create group
      description: |
        Create a new WhatsApp group chat. Returns the group ID and optionally an invite link.

        Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, subject]
              properties:
                accountId: { type: string, description: WhatsApp social account ID }
                subject: { type: string, maxLength: 128, description: Group name (max 128 characters) }
                description: { type: string, maxLength: 2048, description: Group description (max 2048 characters) }
                joinApprovalMode:
                  type: string
                  enum: [approval_required, auto_approve]
                  description: Whether users need approval to join via invite link
      responses:
        '201':
          description: Group created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  group:
                    type: object
                    properties:
                      groupId: { type: string }
                      inviteLink: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/wa-groups/{groupId}:
    get:
      operationId: getWhatsAppGroupChat
      tags: [WhatsApp]
      summary: Get group info
      description: |
        Retrieve metadata about a WhatsApp group including subject, description,
        participants, and settings.

        Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
      security:
        - bearerAuth: []
      parameters:
        - { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
      responses:
        '200':
          description: Group info
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  group:
                    type: object
                    properties:
                      id: { type: string }
                      subject: { type: string }
                      description: { type: string }
                      joinApprovalMode: { type: string }
                      participants:
                        type: array
                        items:
                          type: object
                          properties:
                            user: { type: string, description: Phone number }
                            admin: { type: string }
                      participantCount: { type: integer }
                      createdAt: { type: integer, description: UNIX timestamp }
                      isSuspended: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      operationId: updateWhatsAppGroupChat
      tags: [WhatsApp]
      summary: Update group settings
      description: |
        Update the subject, description, or join approval mode of a WhatsApp group.

        Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
      security:
        - bearerAuth: []
      parameters:
        - { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                subject: { type: string, maxLength: 128 }
                description: { type: string, maxLength: 2048 }
                joinApprovalMode: { type: string, enum: [approval_required, auto_approve] }
      responses:
        '200':
          description: Group updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: deleteWhatsAppGroupChat
      tags: [WhatsApp]
      summary: Delete group
      description: |
        Delete a WhatsApp group and remove all participants.

        Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
      security:
        - bearerAuth: []
      parameters:
        - { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
      responses:
        '200':
          description: Group deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/whatsapp/wa-groups/{groupId}/participants:
    post:
      operationId: addWhatsAppGroupParticipants
      tags: [WhatsApp]
      summary: Add participants
      description: |
        Add participants to a WhatsApp group. Maximum 8 participants per request.

        Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
      security:
        - bearerAuth: []
      parameters:
        - { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phoneNumbers]
              properties:
                phoneNumbers:
                  type: array
                  maxItems: 8
                  items: { type: string }
                  description: Phone numbers in E.164 format (max 8)
      responses:
        '200':
          description: Participants added
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
    delete:
      operationId: removeWhatsAppGroupParticipants
      tags: [WhatsApp]
      summary: Remove participants
      description: |
        Remove participants from a WhatsApp group.

        Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
      security:
        - bearerAuth: []
      parameters:
        - { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phoneNumbers]
              properties:
                phoneNumbers:
                  type: array
                  items: { type: string }
                  description: Phone numbers to remove
      responses:
        '200':
          description: Participants removed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/wa-groups/{groupId}/invite-link:
    post:
      operationId: createWhatsAppGroupInviteLink
      tags: [WhatsApp]
      summary: Create invite link
      description: |
        Create a new invite link for a WhatsApp group. The previous link is revoked.

        Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
      security:
        - bearerAuth: []
      parameters:
        - { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
      responses:
        '200':
          description: Invite link created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  inviteLink: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/wa-groups/{groupId}/join-requests:
    get:
      operationId: listWhatsAppGroupJoinRequests
      tags: [WhatsApp]
      summary: List join requests
      description: |
        List pending join requests for a WhatsApp group (only for groups with approval_required mode).

        Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
      security:
        - bearerAuth: []
      parameters:
        - { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
      responses:
        '200':
          description: Join requests
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  joinRequests:
                    type: array
                    items:
                      type: object
                      properties:
                        user: { type: string, description: Phone number }
                        timestamp: { type: integer, description: UNIX timestamp of request }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: approveWhatsAppGroupJoinRequests
      tags: [WhatsApp]
      summary: Approve join requests
      description: |
        Approve pending join requests for a WhatsApp group.

        Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
      security:
        - bearerAuth: []
      parameters:
        - { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phoneNumbers]
              properties:
                phoneNumbers:
                  type: array
                  items: { type: string }
                  description: Phone numbers to approve
      responses:
        '200':
          description: Requests approved
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
    delete:
      operationId: rejectWhatsAppGroupJoinRequests
      tags: [WhatsApp]
      summary: Reject join requests
      description: |
        Reject pending join requests for a WhatsApp group.

        Not available on [Coexistence](/platforms/whatsapp#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
      security:
        - bearerAuth: []
      parameters:
        - { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phoneNumbers]
              properties:
                phoneNumbers:
                  type: array
                  items: { type: string }
                  description: Phone numbers to reject
      responses:
        '200':
          description: Requests rejected
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }

  # ─── WhatsApp Flows ───────────────────────────────────────────────

  /v1/whatsapp/flows:
    get:
      operationId: listWhatsAppFlows
      tags: [WhatsApp Flows]
      summary: List flows
      description: |
        List all WhatsApp Flows for the Business Account (WABA) associated with the given account.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
      responses:
        '200':
          description: Flows retrieved
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  flows:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        status: { type: string, enum: [DRAFT, PUBLISHED, DEPRECATED, BLOCKED, THROTTLED] }
                        categories:
                          type: array
                          items: { type: string }
                        validation_errors:
                          type: array
                          items: { type: object }
        '400': { description: WABA ID not found on account }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }
    post:
      operationId: createWhatsAppFlow
      tags: [WhatsApp Flows]
      summary: Create flow
      description: |
        Create a new WhatsApp Flow in DRAFT status. Optionally clone an existing flow.
        After creating, upload a Flow JSON definition, then publish to make it sendable.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, name, categories]
              properties:
                accountId: { type: string, description: WhatsApp social account ID }
                name: { type: string, maxLength: 128, description: Flow display name }
                categories:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    enum: [SIGN_UP, SIGN_IN, APPOINTMENT_BOOKING, LEAD_GENERATION, CONTACT_US, CUSTOMER_SUPPORT, SURVEY, OTHER]
                  description: Flow categories
                cloneFlowId: { type: string, description: "Optional: ID of an existing flow to clone" }
            examples:
              basic:
                summary: Create a lead generation flow
                value:
                  accountId: "507f1f77bcf86cd799439011"
                  name: "lead_capture_form"
                  categories: ["LEAD_GENERATION"]
      responses:
        '200':
          description: Flow created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  flow:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      status: { type: string, example: DRAFT }
                      categories:
                        type: array
                        items: { type: string }
        '400': { description: Validation error }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }

  /v1/whatsapp/flows/{flowId}:
    get:
      operationId: getWhatsAppFlow
      tags: [WhatsApp Flows]
      summary: Get flow
      description: |
        Get details for a specific flow, including status, categories, validation errors, and preview URL.
      security:
        - bearerAuth: []
      parameters:
        - { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
        - { name: fields, in: query, schema: { type: string }, description: "Comma-separated fields to return (default: id,name,status,categories,validation_errors,json_version,preview,data_api_version,endpoint_uri)" }
      responses:
        '200':
          description: Flow details
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  flow:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      status: { type: string }
                      categories:
                        type: array
                        items: { type: string }
                      validation_errors:
                        type: array
                        items: { type: object }
                      json_version: { type: string }
                      preview:
                        type: object
                        properties:
                          preview_url: { type: string }
                          expires_at: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Flow or account not found }
    patch:
      operationId: updateWhatsAppFlow
      tags: [WhatsApp Flows]
      summary: Update flow
      description: |
        Update metadata (name, categories) of a DRAFT flow. Published flows are immutable.
      security:
        - bearerAuth: []
      parameters:
        - { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId: { type: string, description: WhatsApp social account ID }
                name: { type: string, maxLength: 128, description: New flow name }
                categories:
                  type: array
                  minItems: 1
                  items:
                    type: string
                    enum: [SIGN_UP, SIGN_IN, APPOINTMENT_BOOKING, LEAD_GENERATION, CONTACT_US, CUSTOMER_SUPPORT, SURVEY, OTHER]
      responses:
        '200':
          description: Flow updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '400': { description: "At least one of name or categories is required, or flow is not in DRAFT status" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account or flow not found }
    delete:
      operationId: deleteWhatsAppFlow
      tags: [WhatsApp Flows]
      summary: Delete flow
      description: |
        Delete a DRAFT flow. This is irreversible. Only flows in DRAFT status can be deleted.
      security:
        - bearerAuth: []
      parameters:
        - { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
      responses:
        '200':
          description: Flow deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '400': { description: Flow is not in DRAFT status }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account or flow not found }

  /v1/whatsapp/flows/{flowId}/json:
    get:
      operationId: getWhatsAppFlowJson
      tags: [WhatsApp Flows]
      summary: Get flow JSON asset
      description: |
        Get the flow JSON asset metadata, including a temporary download URL for the Flow JSON file.
      security:
        - bearerAuth: []
      parameters:
        - { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
      responses:
        '200':
          description: Flow JSON asset
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  assets:
                    type: array
                    items:
                      type: object
                      properties:
                        name: { type: string, example: flow.json }
                        asset_type: { type: string, example: FLOW_JSON }
                        download_url: { type: string, description: Temporary URL to download the flow JSON }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }
    put:
      operationId: uploadWhatsAppFlowJson
      tags: [WhatsApp Flows]
      summary: Upload flow JSON
      description: |
        Upload or update the Flow JSON for a DRAFT flow. The Flow JSON defines all screens,
        components (text inputs, dropdowns, date pickers, etc.), and navigation.

        Meta validates the JSON on upload and returns any validation errors.
        See: https://developers.facebook.com/docs/whatsapp/flows/reference/flowjson
      security:
        - bearerAuth: []
      parameters:
        - { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, flow_json]
              properties:
                accountId: { type: string, description: WhatsApp social account ID }
                flow_json:
                  description: "The Flow JSON content. Pass as a JSON object or a JSON string."
                  oneOf:
                    - type: object
                    - type: string
            examples:
              simple_form:
                summary: Simple lead capture form
                value:
                  accountId: "507f1f77bcf86cd799439011"
                  flow_json:
                    version: "6.0"
                    screens:
                      - id: "LEAD_FORM"
                        title: "Get a Quote"
                        terminal: true
                        success: true
                        layout:
                          type: "SingleColumnLayout"
                          children:
                            - type: "TextInput"
                              name: "full_name"
                              label: "Full Name"
                              required: true
                              input-type: "text"
                            - type: "TextInput"
                              name: "email"
                              label: "Email"
                              required: true
                              input-type: "email"
                            - type: "Footer"
                              label: "Submit"
                              on-click-action:
                                name: "complete"
                                payload:
                                  full_name: "${form.full_name}"
                                  email: "${form.email}"
      responses:
        '200':
          description: Flow JSON uploaded
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  validation_errors:
                    type: array
                    description: "Empty array if valid; otherwise, contains validation error details from Meta"
                    items:
                      type: object
                      properties:
                        error: { type: string }
                        error_type: { type: string }
                        message: { type: string }
                        line_start: { type: integer }
                        line_end: { type: integer }
                        column_start: { type: integer }
                        column_end: { type: integer }
        '400': { description: Invalid JSON or flow is not in DRAFT status }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }

  /v1/whatsapp/flows/{flowId}/publish:
    post:
      operationId: publishWhatsAppFlow
      tags: [WhatsApp Flows]
      summary: Publish flow
      description: |
        Publish a DRAFT flow. This is irreversible. Once published, the flow and its JSON
        become immutable and the flow can be sent to users. To update a published flow,
        create a new flow (optionally cloning this one via cloneFlowId).
      security:
        - bearerAuth: []
      parameters:
        - { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId: { type: string, description: WhatsApp social account ID }
      responses:
        '200':
          description: Flow published
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '400': { description: Flow is not in DRAFT status or has validation errors }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }

  /v1/whatsapp/flows/{flowId}/deprecate:
    post:
      operationId: deprecateWhatsAppFlow
      tags: [WhatsApp Flows]
      summary: Deprecate flow
      description: |
        Deprecate a PUBLISHED flow. This is irreversible. Deprecated flows cannot be sent
        or opened, but existing active sessions may continue until they complete.
      security:
        - bearerAuth: []
      parameters:
        - { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId: { type: string, description: WhatsApp social account ID }
      responses:
        '200':
          description: Flow deprecated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '400': { description: Flow is not in PUBLISHED status }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }

  /v1/whatsapp/flows/send:
    post:
      operationId: sendWhatsAppFlowMessage
      tags: [WhatsApp Flows]
      summary: Send flow message
      description: |
        Send a published flow as an interactive message with a CTA button.
        When the recipient taps the button, the flow opens natively in WhatsApp.
        Flow responses are received via webhooks.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, to, flow_id, flow_cta, body]
              properties:
                accountId: { type: string, description: WhatsApp social account ID }
                to: { type: string, description: "Recipient phone number (E.164 format, e.g. +1234567890)" }
                flow_id: { type: string, description: Published flow ID }
                flow_cta: { type: string, maxLength: 20, description: "CTA button text (e.g. 'Book Now', 'Sign Up')" }
                flow_action:
                  type: string
                  enum: [navigate, data_exchange]
                  default: navigate
                  description: "Action type: navigate opens a screen directly, data_exchange hits your endpoint first"
                flow_token: { type: string, maxLength: 200, description: "Unique token to correlate responses. Auto-generated UUID if omitted." }
                flow_action_payload:
                  type: object
                  properties:
                    screen: { type: string, description: First screen ID to navigate to }
                    data:
                      type: object
                      description: Optional data to pass to the screen
                body: { type: string, description: Message body text }
                header:
                  type: object
                  properties:
                    type: { type: string, enum: [text] }
                    text: { type: string }
                footer: { type: string, description: Optional footer text }
                draft: { type: boolean, description: "Set true to test an unpublished (DRAFT) flow" }
            examples:
              basic:
                summary: Send a lead capture flow
                value:
                  accountId: "507f1f77bcf86cd799439011"
                  to: "+1234567890"
                  flow_id: "1234567890"
                  flow_cta: "Get a Quote"
                  flow_action: "navigate"
                  flow_action_payload:
                    screen: "LEAD_FORM"
                  body: "Hi! Fill out this quick form to get a personalized quote."
      responses:
        '200':
          description: Flow message sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  messageId: { type: string, description: WhatsApp message ID (WAMID) }
        '400': { description: Validation error or missing phone number ID }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }

  # ─── Contacts ─────────────────────────────────────────────────────

  /v1/contacts:
    get:
      operationId: listContacts
      summary: List contacts
      description: List and search contacts for a profile. Supports filtering by tags, platform, subscription status, and full-text search.
      tags: [Contacts]
      parameters:
        - { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
        - { name: search, in: query, schema: { type: string } }
        - { name: tag, in: query, schema: { type: string } }
        - { name: platform, in: query, schema: { type: string, enum: [instagram, facebook, telegram, twitter, bluesky, reddit, whatsapp] } }
        - { name: isSubscribed, in: query, schema: { type: string, enum: ['true', 'false'] } }
        - { name: limit, in: query, schema: { type: integer, default: 50, maximum: 200 } }
        - { name: skip, in: query, schema: { type: integer, default: 0 } }
      responses:
        '200':
          description: Contacts list with pagination and filter metadata
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  contacts:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        email: { type: string }
                        company: { type: string }
                        avatarUrl: { type: string }
                        tags: { type: array, items: { type: string } }
                        isSubscribed: { type: boolean }
                        isBlocked: { type: boolean }
                        lastMessageSentAt: { type: string, format: date-time }
                        lastMessageReceivedAt: { type: string, format: date-time }
                        messagesSentCount: { type: integer }
                        messagesReceivedCount: { type: integer }
                        customFields: { type: object }
                        notes: { type: string }
                        createdAt: { type: string, format: date-time }
                        platform: { type: string }
                        platformIdentifier: { type: string }
                        displayIdentifier: { type: string }
                  filters:
                    type: object
                    properties:
                      tags: { type: array, items: { type: string } }
                  pagination:
                    type: object
                    properties:
                      total: { type: integer }
                      limit: { type: integer }
                      skip: { type: integer }
                      hasMore: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createContact
      summary: Create contact
      description: Create a new contact. Optionally create a platform channel in the same request by providing accountId, platform, and platformIdentifier.
      tags: [Contacts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, name]
              properties:
                profileId: { type: string }
                name: { type: string }
                email: { type: string }
                company: { type: string }
                tags: { type: array, items: { type: string } }
                isSubscribed: { type: boolean, default: true }
                notes: { type: string }
                accountId: { type: string, description: Optional. Creates a channel if provided with platform + platformIdentifier }
                platform: { type: string }
                platformIdentifier: { type: string }
                displayIdentifier: { type: string }
      responses:
        '200':
          description: Contact created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  contact:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      email: { type: string }
                      company: { type: string }
                      tags: { type: array, items: { type: string } }
                      isSubscribed: { type: boolean }
                      isBlocked: { type: boolean }
                      customFields: { type: object }
                      notes: { type: string }
                      createdAt: { type: string, format: date-time }
                  channel:
                    type: object
                    description: Created when accountId, platform, and platformIdentifier are provided
                    properties:
                      id: { type: string }
                      platform: { type: string }
                      platformIdentifier: { type: string }
                      displayIdentifier: { type: string }
                  warning: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { description: Duplicate contact }

  /v1/contacts/{contactId}:
    get:
      operationId: getContact
      summary: Get contact
      description: Returns a contact with all associated messaging channels.
      tags: [Contacts]
      parameters:
        - { name: contactId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Contact with channels
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  contact:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      email: { type: string }
                      company: { type: string }
                      avatarUrl: { type: string }
                      tags: { type: array, items: { type: string } }
                      isSubscribed: { type: boolean }
                      isBlocked: { type: boolean }
                      customFields: { type: object }
                      notes: { type: string }
                      conversationIds: { type: array, items: { type: string } }
                      createdAt: { type: string, format: date-time }
                      updatedAt: { type: string, format: date-time }
                  channels:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        accountId: { type: string }
                        platform: { type: string }
                        platformIdentifier: { type: string }
                        displayIdentifier: { type: string }
                        isSubscribed: { type: boolean }
                        conversationId: { type: string }
                        createdAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updateContact
      summary: Update contact
      description: Update one or more fields on a contact. Only provided fields are changed.
      tags: [Contacts]
      parameters:
        - { name: contactId, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                email: { type: string }
                company: { type: string }
                avatarUrl: { type: string }
                tags: { type: array, items: { type: string } }
                isSubscribed: { type: boolean }
                isBlocked: { type: boolean }
                notes: { type: string }
      responses:
        '200':
          description: Contact updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  contact:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      email: { type: string }
                      company: { type: string }
                      avatarUrl: { type: string }
                      tags: { type: array, items: { type: string } }
                      isSubscribed: { type: boolean }
                      isBlocked: { type: boolean }
                      notes: { type: string }
                      updatedAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: deleteContact
      summary: Delete contact
      description: Permanently deletes a contact and all associated channels.
      tags: [Contacts]
      parameters:
        - { name: contactId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Contact deleted }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/contacts/{contactId}/channels:
    get:
      operationId: getContactChannels
      summary: List channels for a contact
      description: Returns all messaging channels linked to a contact (e.g. Instagram DM, Telegram, WhatsApp).
      tags: [Contacts]
      parameters:
        - { name: contactId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: List of contact channels
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  channels:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        accountId: { type: string }
                        platform: { type: string }
                        platformIdentifier: { type: string }
                        displayIdentifier: { type: string }
                        isSubscribed: { type: boolean }
                        conversationId: { type: string }
                        metadata: { type: object }
                        createdAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/contacts/bulk:
    post:
      operationId: bulkCreateContacts
      summary: Bulk create contacts
      description: Import up to 1000 contacts at a time. Skips duplicates.
      tags: [Contacts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, accountId, platform, contacts]
              properties:
                profileId: { type: string }
                accountId: { type: string }
                platform: { type: string }
                contacts:
                  type: array
                  maxItems: 1000
                  items:
                    type: object
                    required: [name, platformIdentifier]
                    properties:
                      name: { type: string }
                      platformIdentifier: { type: string }
                      displayIdentifier: { type: string }
                      email: { type: string }
                      company: { type: string }
                      tags: { type: array, items: { type: string } }
      responses:
        '200':
          description: Bulk import results
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  created: { type: integer }
                  skipped: { type: integer }
                  errors: { type: array, items: { type: object } }
                  total: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/contacts/{contactId}/fields/{slug}:
    put:
      operationId: setContactFieldValue
      summary: Set custom field value
      description: Set or overwrite a custom field value on a contact. The value type must match the field definition.
      tags: [Custom Fields]
      parameters:
        - { name: contactId, in: path, required: true, schema: { type: string } }
        - { name: slug, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [value]
              properties:
                value: { description: Field value (type depends on field definition) }
      responses:
        '200': { description: Field value set }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: clearContactFieldValue
      summary: Clear custom field value
      description: Remove a custom field value from a contact. The field definition is not affected.
      tags: [Custom Fields]
      parameters:
        - { name: contactId, in: path, required: true, schema: { type: string } }
        - { name: slug, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Field value cleared }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ─── Custom Fields ────────────────────────────────────────────────

  /v1/custom-fields:
    get:
      operationId: listCustomFields
      summary: List custom field definitions
      description: Returns all custom field definitions. Optionally filter by profile.
      tags: [Custom Fields]
      parameters:
        - { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
      responses:
        '200':
          description: List of custom field definitions
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  fields:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        slug: { type: string }
                        type: { type: string, enum: [text, number, date, boolean, select] }
                        options: { type: array, items: { type: string } }
                        createdAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createCustomField
      summary: Create custom field
      description: Create a new custom field definition. Supported types are text, number, date, boolean, and select.
      tags: [Custom Fields]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, name, type]
              properties:
                profileId: { type: string }
                name: { type: string }
                slug: { type: string, description: Auto-generated from name if not provided }
                type: { type: string, enum: [text, number, date, boolean, select] }
                options: { type: array, items: { type: string }, description: Required for select type }
      responses:
        '200':
          description: Custom field created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  field:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      slug: { type: string }
                      type: { type: string, enum: [text, number, date, boolean, select] }
                      options: { type: array, items: { type: string } }
                      createdAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { description: Duplicate slug }

  /v1/custom-fields/{fieldId}:
    patch:
      operationId: updateCustomField
      summary: Update custom field
      description: Update a custom field definition. The field type cannot be changed after creation.
      tags: [Custom Fields]
      parameters:
        - { name: fieldId, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                options: { type: array, items: { type: string } }
      responses:
        '200':
          description: Custom field updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  field:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      slug: { type: string }
                      type: { type: string }
                      options: { type: array, items: { type: string } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: deleteCustomField
      summary: Delete custom field
      description: Delete a custom field definition and remove its values from all contacts.
      tags: [Custom Fields]
      parameters:
        - { name: fieldId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Custom field deleted }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ─── Broadcasts ───────────────────────────────────────────────────

  /v1/broadcasts:
    get:
      operationId: listBroadcasts
      summary: List broadcasts
      description: Returns broadcasts with delivery stats. Filter by status, platform, or profile.
      tags: [Broadcasts]
      parameters:
        - { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
        - { name: status, in: query, schema: { type: string, enum: [draft, scheduled, sending, completed, failed, cancelled] } }
        - { name: platform, in: query, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, default: 50 } }
        - { name: skip, in: query, schema: { type: integer, default: 0 } }
      responses:
        '200':
          description: Broadcasts list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  broadcasts:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        description: { type: string }
                        platform: { type: string }
                        accountId: { type: string }
                        accountName: { type: string, description: Display name of the sending account }
                        status: { type: string, enum: [draft, scheduled, sending, completed, failed, cancelled] }
                        messagePreview: { type: string, description: Template name or message text snippet }
                        scheduledAt: { type: string, format: date-time }
                        startedAt: { type: string, format: date-time }
                        completedAt: { type: string, format: date-time }
                        recipientCount: { type: integer }
                        sentCount: { type: integer }
                        deliveredCount: { type: integer }
                        readCount: { type: integer }
                        failedCount: { type: integer }
                        createdAt: { type: string, format: date-time }
                  pagination:
                    type: object
                    properties:
                      total: { type: integer }
                      limit: { type: integer }
                      skip: { type: integer }
                      hasMore: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createBroadcast
      summary: Create broadcast draft
      description: Create a broadcast in draft status. Add recipients and then send or schedule it.
      tags: [Broadcasts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, accountId, platform, name]
              properties:
                profileId: { type: string }
                accountId: { type: string }
                platform: { type: string, enum: [instagram, facebook, telegram, twitter, bluesky, reddit, whatsapp] }
                name: { type: string }
                description: { type: string }
                message:
                  type: object
                  properties:
                    text: { type: string }
                    attachments: { type: array, items: { type: object, properties: { type: { type: string }, url: { type: string }, filename: { type: string } } } }
                template:
                  type: object
                  description: WhatsApp template (required when platform is whatsapp)
                  properties:
                    name: { type: string }
                    language: { type: string }
                    components: { type: array }
                segmentFilters:
                  type: object
                  properties:
                    tags: { type: array, items: { type: string } }
                    isSubscribed: { type: boolean }
      responses:
        '200':
          description: Broadcast created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  broadcast:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      description: { type: string }
                      platform: { type: string }
                      accountId: { type: string }
                      status: { type: string }
                      createdAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/broadcasts/{broadcastId}:
    get:
      operationId: getBroadcast
      summary: Get broadcast details
      description: Returns a broadcast with its full configuration and delivery stats.
      tags: [Broadcasts]
      parameters:
        - { name: broadcastId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Broadcast details with stats
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  broadcast:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      description: { type: string }
                      platform: { type: string }
                      accountId: { type: string }
                      message: { type: object, properties: { text: { type: string } } }
                      template: { type: object, properties: { name: { type: string }, language: { type: string } } }
                      segmentFilters: { type: object, properties: { tags: { type: array, items: { type: string } } } }
                      status: { type: string, enum: [draft, scheduled, sending, completed, failed, cancelled] }
                      scheduledAt: { type: string, format: date-time }
                      startedAt: { type: string, format: date-time }
                      completedAt: { type: string, format: date-time }
                      recipientCount: { type: integer }
                      sentCount: { type: integer }
                      deliveredCount: { type: integer }
                      readCount: { type: integer }
                      failedCount: { type: integer }
                      createdAt: { type: string, format: date-time }
                      updatedAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updateBroadcast
      summary: Update broadcast
      description: Update a broadcast's name, message, template, or segment filters. Only draft broadcasts can be updated.
      tags: [Broadcasts]
      parameters:
        - { name: broadcastId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Broadcast updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  broadcast:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      description: { type: string }
                      status: { type: string }
                      updatedAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: deleteBroadcast
      summary: Delete broadcast
      description: Permanently delete a broadcast. Only drafts can be deleted.
      tags: [Broadcasts]
      parameters:
        - { name: broadcastId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Broadcast deleted }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/broadcasts/{broadcastId}/send:
    post:
      operationId: sendBroadcast
      summary: Send broadcast now
      description: Immediately start sending a draft broadcast to its recipients.
      tags: [Broadcasts]
      parameters:
        - { name: broadcastId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Broadcast sending started
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  status: { type: string, enum: [sending, completed, failed], description: Current broadcast status after processing first batch }
                  sent: { type: integer, description: Recipients sent in this batch }
                  failed: { type: integer, description: Recipients failed in this batch }
                  recipientCount: { type: integer, description: Total recipient count }
        '400': { description: Invalid status or no recipients }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/broadcasts/{broadcastId}/schedule:
    post:
      operationId: scheduleBroadcast
      summary: Schedule broadcast for later
      description: Schedule a draft broadcast to be sent at a future date and time.
      tags: [Broadcasts]
      parameters:
        - { name: broadcastId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [scheduledAt]
              properties:
                scheduledAt: { type: string, format: date-time }
      responses:
        '200':
          description: Broadcast scheduled
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  broadcast:
                    type: object
                    properties:
                      id: { type: string }
                      status: { type: string }
                      scheduledAt: { type: string, format: date-time }
        '400': { description: Invalid date or status }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/broadcasts/{broadcastId}/cancel:
    post:
      operationId: cancelBroadcast
      summary: Cancel broadcast
      description: Cancel a scheduled or in-progress broadcast. Already-sent messages are not affected.
      tags: [Broadcasts]
      parameters:
        - { name: broadcastId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Broadcast cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  broadcast:
                    type: object
                    properties:
                      id: { type: string }
                      status: { type: string }
        '400': { description: Cannot cancel in current status }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/broadcasts/{broadcastId}/recipients:
    get:
      operationId: listBroadcastRecipients
      summary: List broadcast recipients
      description: Returns recipients for a broadcast with individual delivery status. Filter by status.
      tags: [Broadcasts]
      parameters:
        - { name: broadcastId, in: path, required: true, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [pending, sent, delivered, read, failed] } }
        - { name: limit, in: query, schema: { type: integer, default: 50 } }
        - { name: skip, in: query, schema: { type: integer, default: 0 } }
      responses:
        '200':
          description: Recipients list with delivery status
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  recipients:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        contactId: { type: string }
                        channelId: { type: string }
                        platformIdentifier: { type: string }
                        contactName: { type: string }
                        status: { type: string, enum: [pending, sent, delivered, read, failed] }
                        messageId: { type: string }
                        error: { type: string }
                        sentAt: { type: string, format: date-time }
                        deliveredAt: { type: string, format: date-time }
                        readAt: { type: string, format: date-time }
                  pagination:
                    type: object
                    properties:
                      total: { type: integer }
                      limit: { type: integer }
                      skip: { type: integer }
                      hasMore: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      operationId: addBroadcastRecipients
      summary: Add recipients to a broadcast
      description: Add recipients by contact IDs, raw phone numbers, or from the broadcast's segment filters.
      tags: [Broadcasts]
      parameters:
        - { name: broadcastId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                contactIds: { type: array, items: { type: string }, description: Specific contact IDs to add }
                phones: { type: array, items: { type: string }, description: Raw phone numbers (auto-creates contacts). Useful for WhatsApp/Telegram manual entry }
                useSegment: { type: boolean, description: Auto-populate from broadcast segment filters }
      responses:
        '200':
          description: Recipients added
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  added: { type: integer, description: Number of recipients successfully added }
                  skipped: { type: integer, description: Number skipped (duplicates or missing channels) }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ─── Sequences ────────────────────────────────────────────────────

  /v1/sequences:
    get:
      operationId: listSequences
      summary: List sequences
      description: Returns sequences with enrollment stats. Filter by status, platform, or profile.
      tags: [Sequences]
      parameters:
        - { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
        - { name: status, in: query, schema: { type: string, enum: [draft, active, paused] } }
        - { name: limit, in: query, schema: { type: integer, default: 50 } }
        - { name: skip, in: query, schema: { type: integer, default: 0 } }
      responses:
        '200':
          description: Sequences list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  sequences:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        description: { type: string }
                        platform: { type: string }
                        accountId: { type: string }
                        accountName: { type: string, description: Display name of the sending account }
                        messagePreview: { type: string, description: First step template name or message text snippet }
                        status: { type: string, enum: [draft, active, paused] }
                        stepsCount: { type: integer }
                        exitOnReply: { type: boolean }
                        exitOnUnsubscribe: { type: boolean }
                        totalEnrolled: { type: integer }
                        totalCompleted: { type: integer }
                        totalExited: { type: integer }
                        createdAt: { type: string, format: date-time }
                  pagination:
                    type: object
                    properties:
                      total: { type: integer }
                      limit: { type: integer }
                      skip: { type: integer }
                      hasMore: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createSequence
      summary: Create sequence
      description: Create a multi-step messaging sequence. Each step has a delay and a message or WhatsApp template.
      tags: [Sequences]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, accountId, platform, name]
              properties:
                profileId: { type: string }
                accountId: { type: string }
                platform: { type: string, enum: [instagram, facebook, telegram, twitter, bluesky, reddit, whatsapp] }
                name: { type: string }
                description: { type: string }
                steps:
                  type: array
                  items:
                    type: object
                    required: [order, delayMinutes]
                    properties:
                      order: { type: integer }
                      delayMinutes: { type: integer }
                      message: { type: object, properties: { text: { type: string } } }
                      template:
                        type: object
                        properties:
                          name: { type: string }
                          language: { type: string }
                          variableMapping:
                            type: object
                            description: Maps template variable positions to contact fields. Keys are position strings ("1", "2"), values are objects with field and optional customValue
                            additionalProperties:
                              type: object
                              properties:
                                field: { type: string, enum: [name, phone, email, company, custom] }
                                customValue: { type: string, description: Static value when field is "custom" }
                exitOnReply: { type: boolean, default: true }
                exitOnUnsubscribe: { type: boolean, default: true }
      responses:
        '200':
          description: Sequence created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  sequence:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      description: { type: string }
                      platform: { type: string }
                      status: { type: string }
                      stepsCount: { type: integer }
                      createdAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/sequences/{sequenceId}:
    get:
      operationId: getSequence
      summary: Get sequence with steps
      description: Returns a sequence with all its steps and enrollment stats.
      tags: [Sequences]
      parameters:
        - { name: sequenceId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Sequence details with steps
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  sequence:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      description: { type: string }
                      platform: { type: string }
                      accountId: { type: string }
                      status: { type: string, enum: [draft, active, paused] }
                      steps:
                        type: array
                        items:
                          type: object
                          properties:
                            order: { type: integer }
                            delayMinutes: { type: integer }
                            message: { type: object, properties: { text: { type: string } } }
                            template: { type: object, properties: { name: { type: string }, language: { type: string }, variableMapping: { type: object } } }
                      exitOnReply: { type: boolean }
                      exitOnUnsubscribe: { type: boolean }
                      totalEnrolled: { type: integer }
                      totalCompleted: { type: integer }
                      totalExited: { type: integer }
                      createdAt: { type: string, format: date-time }
                      updatedAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updateSequence
      summary: Update sequence
      description: Update a sequence's name, steps, or exit conditions. Active sequences can be updated without pausing.
      tags: [Sequences]
      parameters:
        - { name: sequenceId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Sequence updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  sequence:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      description: { type: string }
                      status: { type: string }
                      steps: { type: array, items: { type: object } }
                      exitOnReply: { type: boolean }
                      exitOnUnsubscribe: { type: boolean }
                      updatedAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: deleteSequence
      summary: Delete sequence
      description: Permanently delete a sequence. Active enrollments are stopped.
      tags: [Sequences]
      parameters:
        - { name: sequenceId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Sequence deleted }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sequences/{sequenceId}/activate:
    post:
      operationId: activateSequence
      summary: Activate sequence
      description: Start a draft or paused sequence. The sequence must have at least one step.
      tags: [Sequences]
      parameters:
        - { name: sequenceId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Sequence activated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  sequence:
                    type: object
                    properties:
                      id: { type: string }
                      status: { type: string }
        '400': { description: Invalid status or no steps }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sequences/{sequenceId}/pause:
    post:
      operationId: pauseSequence
      summary: Pause sequence
      description: Pause an active sequence. Enrolled contacts stop receiving messages until the sequence is reactivated.
      tags: [Sequences]
      parameters:
        - { name: sequenceId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Sequence paused
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  sequence:
                    type: object
                    properties:
                      id: { type: string }
                      status: { type: string }
        '400': { description: Sequence is not active }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sequences/{sequenceId}/enroll:
    post:
      operationId: enrollContacts
      summary: Enroll contacts in a sequence
      description: Enroll one or more contacts into a sequence. Contacts already enrolled are skipped.
      tags: [Sequences]
      parameters:
        - { name: sequenceId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [contactIds]
              properties:
                contactIds: { type: array, items: { type: string } }
                channelIds: { type: array, items: { type: string }, description: Optional. Auto-detected if not provided. }
      responses:
        '200':
          description: Enrollment results
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  enrolled: { type: integer, description: Number of contacts successfully enrolled }
                  skipped: { type: integer, description: Number skipped (already enrolled or missing channel) }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sequences/{sequenceId}/enroll/{contactId}:
    delete:
      operationId: unenrollContact
      summary: Unenroll contact
      description: Remove a contact from a sequence. No further messages will be sent to this contact.
      tags: [Sequences]
      parameters:
        - { name: sequenceId, in: path, required: true, schema: { type: string } }
        - { name: contactId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Contact unenrolled }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sequences/{sequenceId}/enrollments:
    get:
      operationId: listSequenceEnrollments
      summary: List enrollments for a sequence
      description: Returns enrolled contacts with their progress, status, and next scheduled step.
      tags: [Sequences]
      parameters:
        - { name: sequenceId, in: path, required: true, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [active, completed, exited, paused] } }
        - { name: limit, in: query, schema: { type: integer, default: 50 } }
        - { name: skip, in: query, schema: { type: integer, default: 0 } }
      responses:
        '200':
          description: Enrollments list with progress
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  enrollments:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        contactId: { type: string }
                        channelId: { type: string }
                        platformIdentifier: { type: string }
                        contactName: { type: string }
                        currentStepIndex: { type: integer }
                        status: { type: string, enum: [active, completed, exited, paused] }
                        exitReason: { type: string }
                        nextStepAt: { type: string, format: date-time }
                        stepsSent: { type: integer }
                        lastStepSentAt: { type: string, format: date-time }
                        createdAt: { type: string, format: date-time }
                  pagination:
                    type: object
                    properties:
                      total: { type: integer }
                      limit: { type: integer }
                      skip: { type: integer }
                      hasMore: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ──────────────────────────────────────────────────────────────────────────
  # COMMENT AUTOMATIONS (Comment-to-DM)
  # ──────────────────────────────────────────────────────────────────────────

  /v1/comment-automations:
    get:
      operationId: listCommentAutomations
      tags: [Comment Automations]
      summary: List comment-to-DM automations
      description: List all comment-to-DM automations for a profile. Returns automations with their stats.
      security:
        - bearerAuth: []
      parameters:
        - { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
      responses:
        '200':
          description: Automations list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  automations:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        platform: { type: string, enum: [instagram, facebook] }
                        accountId: { type: string }
                        platformPostId: { type: string }
                        postTitle: { type: string }
                        keywords: { type: array, items: { type: string } }
                        matchMode: { type: string, enum: [exact, contains] }
                        dmMessage: { type: string }
                        commentReply: { type: string }
                        isActive: { type: boolean }
                        stats:
                          type: object
                          properties:
                            triggered: { type: integer }
                            dmsSent: { type: integer }
                            dmsFailed: { type: integer }
                            uniqueContacts: { type: integer }
                        createdAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: createCommentAutomation
      tags: [Comment Automations]
      summary: Create comment-to-DM automation
      description: |
        Create a keyword-triggered DM automation on an Instagram or Facebook post.
        When someone comments a matching keyword, they automatically receive a DM.
        Only one active automation per post is allowed.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, accountId, platformPostId, name, dmMessage]
              properties:
                profileId: { type: string }
                accountId: { type: string, description: Instagram or Facebook account ID }
                platformPostId: { type: string, description: Platform media/post ID }
                postId: { type: string, description: Zernio post ID (optional) }
                postTitle: { type: string, description: Post content snippet for display }
                name: { type: string, description: Automation label }
                keywords:
                  type: array
                  items: { type: string }
                  description: Trigger keywords (empty = any comment triggers)
                matchMode: { type: string, enum: [exact, contains], default: contains }
                dmMessage: { type: string, description: DM text to send to commenter }
                commentReply: { type: string, description: Optional public reply to the comment }
      responses:
        '200':
          description: Automation created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  automation:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      platform: { type: string }
                      platformPostId: { type: string }
                      keywords: { type: array, items: { type: string } }
                      matchMode: { type: string, enum: [exact, contains] }
                      dmMessage: { type: string }
                      commentReply: { type: string }
                      isActive: { type: boolean }
                      stats: { type: object, properties: { totalTriggered: { type: integer }, totalSent: { type: integer }, totalFailed: { type: integer } } }
                      createdAt: { type: string, format: date-time }
        '400': { description: Validation error }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { description: Active automation already exists for this post }

  /v1/comment-automations/{automationId}:
    get:
      operationId: getCommentAutomation
      tags: [Comment Automations]
      summary: Get automation details
      description: Returns an automation with its configuration, stats, and recent trigger logs.
      security:
        - bearerAuth: []
      parameters:
        - { name: automationId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Automation details with stats and recent trigger logs
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  automation:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      platform: { type: string }
                      accountId: { type: string }
                      platformPostId: { type: string }
                      postId: { type: string }
                      postTitle: { type: string }
                      keywords: { type: array, items: { type: string } }
                      matchMode: { type: string, enum: [exact, contains] }
                      dmMessage: { type: string }
                      commentReply: { type: string }
                      isActive: { type: boolean }
                      stats: { type: object, properties: { totalTriggered: { type: integer }, totalSent: { type: integer }, totalFailed: { type: integer } } }
                      createdAt: { type: string, format: date-time }
                      updatedAt: { type: string, format: date-time }
                  logs:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        commentId: { type: string }
                        commenterId: { type: string }
                        commenterName: { type: string }
                        commentText: { type: string }
                        status: { type: string, enum: [sent, failed, skipped], description: DM outcome }
                        error: { type: string, description: DM error message if status is failed }
                        commentReplyStatus: { type: string, enum: [sent, failed, skipped], description: "Outcome of the optional public reply on the triggering comment. 'skipped' if no commentReply was configured or if the DM failed (the public reply is not attempted in that case)." }
                        commentReplyError: { type: string, description: Public-reply error message if commentReplyStatus is failed }
                        createdAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: updateCommentAutomation
      tags: [Comment Automations]
      summary: Update automation settings
      description: Update an automation's keywords, DM message, comment reply, or active status.
      security:
        - bearerAuth: []
      parameters:
        - { name: automationId, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                keywords: { type: array, items: { type: string } }
                matchMode: { type: string, enum: [exact, contains] }
                dmMessage: { type: string }
                commentReply: { type: string }
                isActive: { type: boolean }
      responses:
        '200':
          description: Automation updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  automation:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      keywords: { type: array, items: { type: string } }
                      matchMode: { type: string, enum: [exact, contains] }
                      dmMessage: { type: string }
                      commentReply: { type: string }
                      isActive: { type: boolean }
                      updatedAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: deleteCommentAutomation
      tags: [Comment Automations]
      summary: Delete automation
      description: Permanently delete an automation and all its trigger logs.
      security:
        - bearerAuth: []
      parameters:
        - { name: automationId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Automation deleted }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/comment-automations/{automationId}/logs:
    get:
      operationId: listCommentAutomationLogs
      tags: [Comment Automations]
      summary: List automation logs
      description: Paginated list of every comment that triggered this automation, with send status and commenter info.
      security:
        - bearerAuth: []
      parameters:
        - { name: automationId, in: path, required: true, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [sent, failed, skipped] }, description: Filter by result status }
        - { name: limit, in: query, schema: { type: integer, default: 50 } }
        - { name: skip, in: query, schema: { type: integer, default: 0 } }
      responses:
        '200':
          description: Trigger logs with pagination
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  logs:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        commentId: { type: string }
                        commenterId: { type: string }
                        commenterName: { type: string }
                        commentText: { type: string }
                        status: { type: string, enum: [sent, failed, skipped], description: DM outcome }
                        error: { type: string, description: DM error message if status is failed }
                        commentReplyStatus: { type: string, enum: [sent, failed, skipped], description: "Outcome of the optional public reply on the triggering comment. 'skipped' if no commentReply was configured or if the DM failed (the public reply is not attempted in that case)." }
                        commentReplyError: { type: string, description: Public-reply error message if commentReplyStatus is failed }
                        createdAt: { type: string, format: date-time }
                  pagination:
                    type: object
                    properties:
                      total: { type: integer }
                      limit: { type: integer }
                      skip: { type: integer }
                      hasMore: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  # ── Ads ──────────────────────────────────────────────────────────────────

  /v1/ads:
    get:
      operationId: listAds
      tags: [Ads]
      summary: List ads
      description: |
        Returns a paginated list of ads with metrics computed over an optional date range.
        Use source=all to include externally-synced ads from platform ad managers.
        If no date range is provided, defaults to the last 90 days. Date range is capped at 90 days max.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 50 } }
        - { name: source, in: query, schema: { type: string, enum: [zernio, all], default: all }, description: "all (default) = Zernio-created + platform-discovered ads. zernio = restrict to Zernio-created only." }
        - { name: status, in: query, schema: { $ref: '#/components/schemas/AdStatus' } }
        - { name: platform, in: query, schema: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] } }
        - { name: accountId, in: query, schema: { type: string }, description: Social account ID }
        - { name: adAccountId, in: query, schema: { type: string }, description: "Platform ad account ID (e.g. act_123 for Meta). Mirrors the same filter on /v1/ads/campaigns and /v1/ads/tree." }
        - { name: profileId, in: query, schema: { type: string }, description: Profile ID }
        - { name: campaignId, in: query, schema: { type: string }, description: Platform campaign ID (filter ads within a campaign) }
        - { name: fromDate, in: query, schema: { type: string, format: date }, description: "Start of metrics date range (YYYY-MM-DD). Defaults to 90 days ago." }
        - { name: toDate, in: query, schema: { type: string, format: date }, description: "End of metrics date range (YYYY-MM-DD). Defaults to today. Max 90-day range." }
      responses:
        '200':
          description: Paginated ads
          content:
            application/json:
              schema:
                type: object
                properties:
                  ads:
                    type: array
                    items: { $ref: '#/components/schemas/Ad' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads add-on required

  /v1/ads/campaigns:
    get:
      operationId: listAdCampaigns
      tags: [Ad Campaigns]
      summary: List campaigns
      description: |
        Returns campaigns as virtual aggregations over ad documents grouped by platform campaign ID.
        Metrics (spend, impressions, clicks, etc.) are summed across all ads in each campaign.
        Campaign status is derived from child ad statuses (active > pending_review > paused > error > completed > cancelled > rejected).
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 } }
        - { name: source, in: query, schema: { type: string, enum: [zernio, all], default: all }, description: "`all` (default) returns both Zernio-created ads and those discovered from the platform's ad manager — matches the web UI's default view. Pass `zernio` to restrict to isExternal=false only. Status is NOT filtered by default — use the `status` param for that." }
        - { name: platform, in: query, schema: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] } }
        - { name: status, in: query, schema: { $ref: '#/components/schemas/AdStatus' }, description: Filter by derived campaign status (post-aggregation) }
        - { name: adAccountId, in: query, schema: { type: string }, description: Platform ad account ID (e.g. act_123 for Meta) }
        - { name: accountId, in: query, schema: { type: string }, description: Social account ID }
        - { name: profileId, in: query, schema: { type: string }, description: Profile ID }
      responses:
        '200':
          description: Paginated campaigns
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaigns:
                    type: array
                    items: { $ref: '#/components/schemas/AdCampaign' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads add-on required

  /v1/ads/campaigns/{campaignId}/status:
    put:
      operationId: updateAdCampaignStatus
      tags: [Ad Campaigns]
      summary: Pause or resume a campaign
      description: |
        Updates the status of all ads in a campaign. Makes one platform API call (not per-ad) since status cascades through the campaign hierarchy.
        Ads in terminal statuses (rejected, completed, cancelled) are automatically skipped.
      security:
        - bearerAuth: []
      parameters:
        - { name: campaignId, in: path, required: true, schema: { type: string }, description: Platform campaign ID }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status, platform]
              properties:
                status: { type: string, enum: [active, paused] }
                platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] }
      responses:
        '200':
          description: Campaign status updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated: { type: integer, description: Number of ads updated }
                  skipped: { type: integer, description: Number of ads skipped }
                  skippedReasons: { type: array, items: { type: string } }
                  message: { type: string, description: Human-readable summary (present when no ads were actionable) }
        '400':
          description: Invalid input or campaign spans multiple social accounts
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: No ads found for this campaign

  /v1/ads/campaigns/{campaignId}:
    put:
      operationId: updateAdCampaign
      tags: [Ad Campaigns]
      summary: Update a campaign (budget and/or bid strategy)
      description: |
        Campaign-level edits. At least one of `budget` or `bidStrategy` is required.

        - `budget` updates the CBO (Campaign Budget Optimization) budget. For ABO campaigns
          (where the budget lives on the ad set), use PUT /v1/ads/ad-sets/{adSetId} instead — this endpoint
          will return 409 with code BUDGET_LEVEL_MISMATCH.
        - `bidStrategy` sets the campaign-level default bid strategy. Per Meta's spec, `bid_amount` and
          `bid_constraints` do NOT exist at the campaign level — pass them via PUT /v1/ads/ad-sets/{adSetId}.

        Meta-only for now. Other platforms return 501 Not Implemented.
      security:
        - bearerAuth: []
      parameters:
        - { name: campaignId, in: path, required: true, schema: { type: string }, description: Platform campaign ID }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platform]
              properties:
                platform: { type: string, enum: [facebook, instagram] }
                budget:
                  type: object
                  required: [amount, type]
                  properties:
                    amount: { type: number, description: Budget amount in the ad account's currency }
                    type: { type: string, enum: [daily, lifetime] }
                bidStrategy:
                  allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
                  description: "Campaign-level default. Ad sets inherit this unless they override."
      responses:
        '200':
          description: Campaign updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated: { type: integer }
                  budget: { $ref: '#/components/schemas/AdBudget' }
                  budgetLevel: { type: string, enum: [campaign] }
                  bidStrategy: { $ref: '#/components/schemas/BidStrategy' }
        '400': { description: Invalid input }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Campaign not found }
        '409': { description: "Campaign is ABO — route to /v1/ads/ad-sets/{adSetId} instead" }
        '501': { description: Operation not supported on this platform }

    delete:
      operationId: deleteAdCampaign
      tags: [Ad Campaigns]
      summary: Delete a campaign
      description: |
        Deletes the whole campaign on the platform, cascading to its ad sets
        and ads. Locally, all Ad documents for this campaign are marked
        `status: cancelled`.

        Meta-only for now. Other platforms return 501 Not Implemented — fall
        back to DELETE /v1/ads/{adId} per ad in the meantime.
      security:
        - bearerAuth: []
      parameters:
        - { name: campaignId, in: path, required: true, schema: { type: string }, description: Platform campaign ID }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platform]
              properties:
                platform: { type: string, enum: [facebook, instagram] }
      responses:
        '200':
          description: Campaign deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }
                  adCount: { type: integer, description: Number of local Ad docs marked cancelled }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Campaign not found }
        '501': { description: Operation not supported on this platform }

  /v1/ads/campaigns/bulk-status:
    post:
      operationId: bulkUpdateAdCampaignStatus
      tags: [Ad Campaigns]
      summary: Pause or resume many campaigns
      description: |
        Process up to 50 campaigns in one call. Each campaign is updated
        concurrently and the response contains a per-campaign result so a
        single bad row does not fail the whole batch.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status, campaigns]
              properties:
                status: { type: string, enum: [active, paused] }
                campaigns:
                  type: array
                  maxItems: 50
                  items:
                    type: object
                    required: [platformCampaignId, platform]
                    properties:
                      platformCampaignId: { type: string }
                      platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] }
      responses:
        '200':
          description: Per-campaign results
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [active, paused] }
                  totals:
                    type: object
                    properties:
                      updated: { type: integer }
                      skipped: { type: integer }
                      failed: { type: integer }
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        platformCampaignId: { type: string }
                        platform: { type: string }
                        updated: { type: integer }
                        skipped: { type: integer }
                        error: { type: string }
        '400': { description: Invalid input }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/ads/campaigns/{campaignId}/duplicate:
    post:
      operationId: duplicateAdCampaign
      tags: [Ad Campaigns]
      summary: Duplicate a campaign
      description: |
        Duplicates a campaign, including its ad sets, ads, creatives, and
        targeting by default (`deepCopy: true`). The copy is created paused
        so callers can review before launching.

        Per-platform implementation:
        - **Meta** uses the native `POST /{campaign-id}/copies` endpoint.
        - **TikTok** has no native copy primitive; Zernio walks the source
          graph (`/v2/campaign/get/`, `/v2/adgroup/get/`, `/v2/ad/get/`) and
          recreates each entity via the corresponding `/create/` endpoints,
          carrying over budget / targeting / bid_type / bid_price /
          deep_bid_type / creative fields. Spark Ad linkage (`tiktok_item_id`)
          is preserved.

        The new hierarchy is asynchronous to materialize in our DB — we
        trigger sync discovery automatically. Set `syncAfter: false` to
        skip and poll `/v1/ads/tree` on your own cadence.

        Other platforms return 501 Not Implemented.
      security:
        - bearerAuth: []
      parameters:
        - { name: campaignId, in: path, required: true, schema: { type: string }, description: Source platform campaign ID }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platform]
              properties:
                platform: { type: string, enum: [facebook, instagram, tiktok] }
                deepCopy: { type: boolean, default: true, description: Copy child ad sets + ads + creatives + targeting }
                statusOption:
                  type: string
                  enum: [ACTIVE, PAUSED, INHERITED_FROM_SOURCE]
                  default: PAUSED
                startTime: { type: string, format: date-time, description: Reschedule the copied hierarchy's start time }
                endTime: { type: string, format: date-time }
                renameStrategy:
                  type: string
                  enum: [DEEP_RENAME, ONLY_TOP_LEVEL_RENAME, NO_RENAME]
                renamePrefix: { type: string }
                renameSuffix: { type: string }
                syncAfter: { type: boolean, default: true, description: Trigger ads discovery on the owning account after the copy succeeds }
      responses:
        '200':
          description: Campaign duplicated
          content:
            application/json:
              schema:
                type: object
                properties:
                  copiedCampaignId: { type: string, description: Platform ID of the new campaign }
                  discovery: { type: string, enum: [triggered, skipped, failed] }
                  raw:
                    type: object
                    description: Platform-native response from the copy endpoint (Meta includes ad_object_ids for child copies)
        '400': { description: Invalid input }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Source campaign not found }
        '501': { description: Operation not supported on this platform }

  /v1/ads/ad-sets/{adSetId}:
    put:
      operationId: updateAdSet
      tags: [Ad Campaigns]
      summary: Update an ad set (budget, status, and/or bid strategy)
      description: |
        Ad-set-level writes. Use this for ABO budget updates, ad-set-scoped
        pause/resume, and bid-strategy edits. At least one of `budget`,
        `status`, or `bidStrategy` is required.

        Bid strategy compatibility (per Meta's spec):
        - `LOWEST_COST_WITHOUT_CAP`: no `bidAmount`, no `roasAverageFloor`.
        - `LOWEST_COST_WITH_BID_CAP` / `COST_CAP`: `bidAmount` REQUIRED (whole currency units).
        - `LOWEST_COST_WITH_MIN_ROAS`: `roasAverageFloor` REQUIRED (decimal multiplier, e.g. 2.0 = 2.0x ROAS).

        When updating `budget` on an ABO campaign: if the parent campaign is
        CBO, the response is 409 with code BUDGET_LEVEL_MISMATCH — route to
        PUT /v1/ads/campaigns/{campaignId} instead.
      security:
        - bearerAuth: []
      parameters:
        - { name: adSetId, in: path, required: true, schema: { type: string }, description: Platform ad set ID }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platform]
              properties:
                platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] }
                budget:
                  type: object
                  description: Omit if not updating budget
                  properties:
                    amount: { type: number }
                    type: { type: string, enum: [daily, lifetime] }
                status: { type: string, enum: [active, paused], description: Omit if not toggling delivery state }
                bidStrategy:
                  allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
                  description: |
                    Ad-set-level bid strategy. Overrides the campaign-level default.
                    Supported on Meta (facebook, instagram) and TikTok. On TikTok the
                    Meta-style enum is mapped to bid_type / bid_price / deep_bid_type
                    automatically. Other platforms (linkedin, pinterest, google, twitter)
                    return 501 Not Implemented when bidStrategy is set.
                bidAmount:
                  type: number
                  description: |
                    Bid cap in WHOLE currency units (USD: 5 = $5.00; JPY: 100 = ¥100). Required when
                    bidStrategy is LOWEST_COST_WITH_BID_CAP or COST_CAP. Internally converted to Meta's
                    smallest-denomination integer.
                roasAverageFloor:
                  type: number
                  description: |
                    Minimum ROAS as a decimal multiplier (2.0 = 2.0x). Required when bidStrategy is
                    LOWEST_COST_WITH_MIN_ROAS. Sent to Meta as `bid_constraints.roas_average_floor` × 10000.
      responses:
        '200':
          description: Ad set updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  budget: { $ref: '#/components/schemas/AdBudget' }
                  budgetLevel: { type: string, enum: [adset] }
                  status: { type: string, enum: [active, paused] }
                  statusUpdated: { type: integer }
                  statusSkipped: { type: integer }
                  bidStrategy: { $ref: '#/components/schemas/BidStrategy' }
                  bidAmount: { type: number, nullable: true }
                  roasAverageFloor: { type: number, nullable: true }
        '400': { description: Invalid input }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Ad set not found }
        '409': { description: "Campaign is CBO — route to /v1/ads/campaigns/{campaignId} instead" }
        '501': { description: "bidStrategy not supported on the platform (Meta + TikTok only)" }

  /v1/ads/ad-sets/{adSetId}/status:
    put:
      operationId: updateAdSetStatus
      tags: [Ad Campaigns]
      summary: Pause or resume a single ad set
      description: |
        Ad-set-scoped pause/resume (doesn't touch sibling ad sets). Thin wrapper
        over PUT /v1/ads/ad-sets/{adSetId} for callers that only want the
        status toggle and prefer a symmetric URL to
        /v1/ads/campaigns/{campaignId}/status.
      security:
        - bearerAuth: []
      parameters:
        - { name: adSetId, in: path, required: true, schema: { type: string }, description: Platform ad set ID }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status, platform]
              properties:
                status: { type: string, enum: [active, paused] }
                platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] }
      responses:
        '200':
          description: Ad set status updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated: { type: integer }
                  skipped: { type: integer }
        '400': { description: Invalid input }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Ad set not found }

  /v1/ads/tree:
    get:
      operationId: getAdTree
      tags: [Ad Campaigns]
      summary: Get campaign tree
      description: |
        Returns a nested Campaign > Ad Set > Ad hierarchy with rolled-up metrics at each level.
        Uses a two-stage aggregation: ads are grouped into ad sets, then ad sets into campaigns.
        Metrics are computed over an optional date range, then rolled up from ad level to ad set
        and campaign levels. Pagination is at the campaign level. Ads without a campaign or ad set
        ID are grouped into synthetic "Ungrouped" buckets.
        If no date range is provided, defaults to the last 90 days. Date range is capped at 90 days max.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 }, description: Campaigns per page }
        - { name: source, in: query, schema: { type: string, enum: [zernio, all], default: all }, description: "`all` (default) returns both Zernio-created ads and those discovered from the platform's ad manager — matches the web UI's default view. Pass `zernio` to restrict to isExternal=false only. Status is NOT filtered by default — use the `status` param for that." }
        - { name: platform, in: query, schema: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] } }
        - { name: status, in: query, schema: { $ref: '#/components/schemas/AdStatus' }, description: Filter by derived campaign status (post-aggregation) }
        - { name: adAccountId, in: query, schema: { type: string }, description: Platform ad account ID }
        - { name: accountId, in: query, schema: { type: string }, description: Social account ID }
        - { name: profileId, in: query, schema: { type: string }, description: Profile ID }
        - { name: fromDate, in: query, schema: { type: string, format: date }, description: "Start of metrics date range (YYYY-MM-DD). Defaults to 90 days ago." }
        - { name: toDate, in: query, schema: { type: string, format: date }, description: "End of metrics date range (YYYY-MM-DD). Defaults to today. Max 90-day range." }
      responses:
        '200':
          description: Nested campaign tree with pagination
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaigns:
                    type: array
                    items: { $ref: '#/components/schemas/AdTreeCampaign' }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads add-on required

  /v1/ads/{adId}:
    get:
      operationId: getAd
      tags: [Ads]
      summary: Get ad details
      description: Returns an ad with its creative, targeting, status, and performance metrics.
      security:
        - bearerAuth: []
      parameters:
        - { name: adId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Ad details
          content:
            application/json:
              schema:
                type: object
                properties:
                  ad: { $ref: '#/components/schemas/Ad' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      operationId: updateAd
      tags: [Ads]
      summary: Update ad
      description: |
        Patch one or more fields on an ad. Status, budget, targeting, and creative changes
        are propagated to the platform.

        Per-platform support:
        - **Meta** (Facebook + Instagram): all fields supported.
        - **TikTok**: status, budget, targeting (via `/v2/adgroup/update/`), and creative
          (via `/v2/ad/update/` patch-style — `headline` is ignored, `body` becomes `ad_text`).
        - **Pinterest / X / LinkedIn / Google**: status + budget only. Sending `targeting`
          or `creative` returns 501 with code `unsupported_platform_operation`.
      security:
        - bearerAuth: []
      parameters:
        - { name: adId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                status: { type: string, enum: [active, paused] }
                budget:
                  type: object
                  properties:
                    amount: { type: number, description: "Minimum varies by platform: TikTok=$20, Pinterest=$5, others=$1" }
                    type: { type: string, enum: [daily, lifetime] }
                targeting:
                  type: object
                  description: |
                    Meta + TikTok only. Pinterest / X / LinkedIn / Google return 501.
                  properties:
                    ageMin: { type: integer, minimum: 13, maximum: 65 }
                    ageMax: { type: integer, minimum: 13, maximum: 65 }
                    countries: { type: array, items: { type: string } }
                    interests:
                      type: array
                      description: "Interest objects from /v1/ads/interests. Each must include id and name."
                      items:
                        type: object
                        required: [id, name]
                        properties:
                          id: { type: string }
                          name: { type: string }
                    advantage_audience: { type: integer, enum: [0, 1], description: "Meta only. Omit to preserve the existing setting on update. 0 = disabled, 1 = enabled." }
                creative:
                  type: object
                  description: |
                    Replace the ad's creative. Meta + TikTok only.

                    - **Meta**: requires `headline`, `body`, `callToAction`, `linkUrl`, `imageUrl`. The
                      ad's existing creative is replaced via a new `/act_X/adcreatives` upload + ad
                      update. The old creative is retained on the ad account for historical reporting.
                    - **TikTok**: patch-style. Pass any subset; `headline` is ignored (TikTok creatives
                      have no headline slot). `body` becomes the in-feed `ad_text`; `linkUrl` becomes
                      `landing_page_url`; `videoUrl` triggers a fresh upload.
                  properties:
                    headline: { type: string, description: "Meta only" }
                    body: { type: string }
                    callToAction: { type: string }
                    linkUrl: { type: string, format: uri }
                    imageUrl: { type: string, format: uri }
                    videoUrl: { type: string, format: uri }
                name: { type: string }
      responses:
        '200':
          description: Ad updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  ad: { $ref: '#/components/schemas/Ad' }
                  message: { type: string }
        '400':
          description: Invalid status transition or budget below minimum
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '501': { description: "targeting or creative not supported on the platform (Meta + TikTok only)" }
    delete:
      operationId: deleteAd
      tags: [Ads]
      summary: Cancel an ad
      description: Cancels the ad on the platform and marks it as cancelled in the database. The ad is preserved for history.
      security:
        - bearerAuth: []
      parameters:
        - { name: adId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Ad cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/ads/{adId}/analytics:
    get:
      operationId: getAdAnalytics
      tags: [Ads]
      summary: Get ad analytics
      description: |
        Returns detailed performance analytics for an ad. Includes summary metrics, a daily timeline
        over the requested date range, and optional demographic breakdowns (Meta and TikTok only).
        If no date range is provided, defaults to the last 90 days. Date range is capped at 90 days max.
      security:
        - bearerAuth: []
      parameters:
        - { name: adId, in: path, required: true, schema: { type: string } }
        - { name: fromDate, in: query, schema: { type: string, format: date }, description: "Start of date range (YYYY-MM-DD). Defaults to 90 days ago." }
        - { name: toDate, in: query, schema: { type: string, format: date }, description: "End of date range (YYYY-MM-DD). Defaults to today. Max 90-day range." }
        - name: breakdowns
          in: query
          schema: { type: string }
          description: "Comma-separated breakdown dimensions. Meta: age, gender, country, publisher_platform, device_platform, region. TikTok: gender, age, country_code, platform, ac, language."
      responses:
        '200':
          description: Ad analytics
          content:
            application/json:
              schema:
                type: object
                properties:
                  ad:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      platform: { type: string }
                      status: { type: string }
                  analytics:
                    type: object
                    properties:
                      summary: { $ref: '#/components/schemas/AdMetrics' }
                      daily:
                        type: array
                        items:
                          allOf:
                            - { $ref: '#/components/schemas/AdMetrics' }
                            - type: object
                              properties:
                                date: { type: string, format: date }
                      breakdowns:
                        type: object
                        additionalProperties:
                          type: array
                          items: { type: object }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads add-on required
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/ads/{adId}/comments:
    get:
      operationId: getAdComments
      tags: [Ads]
      summary: List comments on an ad
      description: |
        Returns comments on an ad's underlying creative post. Useful for moderating or analyzing
        engagement on dark posts (ad creatives that never went live organically), which the
        regular GET /v1/inbox/comments/{postId} endpoint cannot serve because dark posts are
        not in Zernio's post database.

        Resolves the ad's creative effective_object_story_id (Facebook) or
        effective_instagram_media_id (Instagram) via the Marketing API on each call
        (cached in-process by the platform client), then fetches comments from the Graph API.

        Meta-only. Other ad platforms (TikTok, LinkedIn, Pinterest, Google, X) do not
        expose a public per-ad comments API and return feature_not_available.

        Requires the Ads add-on. Response shape matches GET /v1/inbox/comments/{postId}.
      security:
        - bearerAuth: []
      parameters:
        - { name: adId, in: path, required: true, schema: { type: string }, description: "Internal Zernio ad ID (ObjectId)." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: cursor, in: query, schema: { type: string }, description: "Pagination cursor from a previous response." }
      responses:
        '200':
          description: Comments on the ad
          content:
            application/json:
              schema:
                type: object
                required: [status, comments, pagination, meta]
                properties:
                  status: { type: string, enum: [success] }
                  comments:
                    type: array
                    items:
                      type: object
                      description: Normalized comment. Same shape as /v1/inbox/comments/{postId} responses.
                  pagination:
                    type: object
                    properties:
                      hasMore: { type: boolean }
                      cursor: { type: string }
                  meta:
                    type: object
                    required: [platform, adId, platformAdId, effectiveStoryId, accountId, lastUpdated]
                    properties:
                      platform: { type: string, enum: [facebook, instagram] }
                      adId: { type: string, description: "Internal Zernio ad ID." }
                      platformAdId: { type: string, description: "Meta ad ID." }
                      effectiveStoryId:
                        type: string
                        description: "Underlying post ID the comments belong to. effective_object_story_id for Facebook, effective_instagram_media_id for Instagram."
                      accountId: { type: string, description: "Social account ID (ads SocialAccount)." }
                      lastUpdated: { type: string, format: date-time }
        '400':
          description: |
            Invalid ad ID format, or the ad's creative format does not expose a commentable
            underlying post (code ad_not_commentable).
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads add-on required, or ad platform is not Meta (code feature_not_available).
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Ads connection missing or account token unavailable (code ads_connection_required).

  /v1/ads/business-centers:
    get:
      operationId: listAdsBusinessCenters
      tags: [Ads]
      summary: List TikTok Business Centers
      description: |
        Returns the TikTok Business Centers (BCs) the connected `tiktokads` account can read.
        Each BC reports its advertiser count so callers can build agency-style pickers
        without re-walking `/v1/ads/accounts` per BC.

        TikTok-only. Solo advertisers (non-agency tokens) return an empty array.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: ID of the `tiktokads` (or parent `tiktok` posting) SocialAccount }
      responses:
        '200':
          description: Business centers
          content:
            application/json:
              schema:
                type: object
                properties:
                  businessCenters:
                    type: array
                    items: { $ref: '#/components/schemas/BusinessCenter' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: TikTok account not found }
        '422': { description: TikTok Ads not connected }

  /v1/ads/accounts:
    get:
      operationId: listAdAccounts
      tags: [Ads]
      summary: List ad accounts
      description: |
        Returns the platform ad accounts available for the given social account (e.g. Meta ad
        accounts, TikTok advertiser IDs, Google Ads customer IDs).

        For TikTok agencies: enumerates every advertiser under every Business Center the token
        can read (paginated server-side), then chunks the lookup against TikTok's
        `/advertiser/info/` endpoint (which has a per-call cap of ≤100 IDs). Solo advertisers
        without a BC fall back to the OAuth-time `advertiser_ids` list. Cached for 1h on the
        SocialAccount; lazy-refreshed on first call after expiry.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: Social account ID }
        - { name: adAccountId, in: query, required: false, schema: { type: string }, description: "Filter response to a single platform ad account ID (e.g. `act_123` for Meta, advertiser_id for TikTok). Returns at most one item." }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 1000 }, description: "Clamp the returned `accounts[]` length. Useful for typeahead pickers on agency tokens with hundreds of advertisers." }
      responses:
        '200':
          description: Ad accounts
          content:
            application/json:
              schema:
                type: object
                properties:
                  accounts:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: "Platform ad account ID (e.g. act_123)" }
                        name: { type: string }
                        currency: { type: string }
                        status: { type: string }
                        timezoneName: { type: string, description: "IANA timezone of the ad account (Meta only). Drives daily-budget reset and Insights day boundaries." }
                        timezoneOffsetHoursUtc: { type: number, description: "Signed UTC offset in hours, reflecting current DST (Meta only)." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422':
          description: Platform ads connection required (TikTok Ads, X Ads) or Instagram missing linked Facebook account

  /v1/ads/boost:
    post:
      operationId: boostPost
      tags: [Ads]
      summary: Boost post as ad
      description: Creates a paid ad campaign from an existing published post. Creates the full platform campaign hierarchy (campaign, ad set, ad).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, adAccountId, name, goal, budget]
              properties:
                postId: { type: string, description: Zernio post ID (provide this or platformPostId) }
                platformPostId: { type: string, description: Platform post ID (alternative to postId) }
                accountId: { type: string, description: Social account ID }
                adAccountId: { type: string, description: Platform ad account ID }
                name: { type: string, maxLength: 255 }
                goal: { type: string, enum: [engagement, traffic, awareness, video_views, lead_generation, conversions, app_promotion], description: "Available goals vary by platform. Meta (Facebook/Instagram) and TikTok support all 7. LinkedIn supports all except app_promotion. Twitter/X supports engagement, traffic, awareness, video_views, app_promotion. Pinterest and Google Ads support only engagement, traffic, awareness, video_views." }
                budget:
                  type: object
                  required: [amount, type]
                  properties:
                    amount: { type: number, description: "Minimum varies: TikTok=$20, Pinterest=$5, others=$1" }
                    type: { type: string, enum: [daily, lifetime] }
                currency: { type: string, example: USD }
                schedule:
                  type: object
                  properties:
                    startDate: { type: string, format: date-time }
                    endDate: { type: string, format: date-time, description: Required for lifetime budgets }
                targeting:
                  type: object
                  properties:
                    ageMin: { type: integer, minimum: 13, maximum: 65 }
                    ageMax: { type: integer, minimum: 13, maximum: 65 }
                    countries: { type: array, items: { type: string } }
                    interests:
                      type: array
                      description: "Interest objects from /v1/ads/interests. Each must include id and name."
                      items:
                        type: object
                        required: [id, name]
                        properties:
                          id: { type: string }
                          name: { type: string }
                    advantage_audience: { type: integer, enum: [0, 1], description: "Meta only. 0 = disabled (default), 1 = enabled." }
                bidStrategy:
                  allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
                  description: |
                    Meta bid strategy applied to the ad set. On TikTok, mapped to
                    `bid_type` / `bid_price` / `deep_bid_type` automatically.
                bidAmount:
                  type: number
                  description: |
                    Bid cap in WHOLE currency units (USD: 5 = $5.00; JPY: 100 = ¥100). Required when
                    `bidStrategy` is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`. Backward-compat: providing
                    `bidAmount` without `bidStrategy` is treated as `LOWEST_COST_WITH_BID_CAP`.
                roasAverageFloor:
                  type: number
                  description: |
                    Minimum ROAS as a decimal multiplier (e.g. 2.0 = 2.0x ROAS). Required when
                    `bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`. Sent to Meta as
                    `bid_constraints.roas_average_floor` × 10000 (Meta uses fixed-point integers).
                tracking:
                  type: object
                  description: "Meta only. Tracking specs (pixel, URL tags)."
                  properties:
                    pixelId: { type: string }
                    urlTags: { type: string }
                specialAdCategories:
                  type: array
                  description: "Meta only. Required for housing, employment, credit, or political ads."
                  items: { type: string, enum: [HOUSING, EMPLOYMENT, CREDIT, ISSUES_ELECTIONS_POLITICS] }
                linkUrl:
                  type: string
                  format: uri
                  description: |
                    TikTok-only. Custom destination URL for the Spark Ad. Without this, TikTok
                    Spark Ads have no clickable destination — required for traffic / conversion
                    objectives. Maps to `landing_page_url` on the creative entry of /v2/ad/create/
                    (TikTok SDK `AdcreateCreatives.landing_page_url`). Ignored on Meta / LinkedIn /
                    Pinterest / X / Google (those infer the destination from the boosted post).
                callToAction:
                  type: string
                  description: |
                    TikTok-only. Call-to-action button label on the Spark Ad creative (e.g.
                    `LEARN_MORE`, `SHOP_NOW`, `DOWNLOAD_NOW`, `SIGN_UP`, `WATCH_NOW`). Maps to
                    `call_to_action` on the creative entry of /v2/ad/create/. Pass-through —
                    the platform validates the value. See TikTok's "Enumeration - Call-to-Action"
                    reference for the full list.
                sparkAuthCode:
                  type: string
                  description: |
                    TikTok-only. Spark Code (creator's `auth_code`) authorizing cross-creator
                    Spark Ads — the advertiser can boost a video owned by a DIFFERENT TikTok
                    account. Without this, boosts are limited to videos owned by the same
                    account running the ads (same-BC creators only). The creator generates the
                    code in their TikTok app's Promote settings and shares it with the
                    advertiser. Maps to `auth_code` on the creative entry of /v2/ad/create/.
                dsaBeneficiary:
                  type: string
                  maxLength: 100
                  description: |
                    Name of the legal entity benefiting from the ad.
                    Required by Meta when targeting EU users (DSA Article 26).
                    Not enforced at schema level; enforced server-side when targeting intersects EU member states.
                dsaPayor:
                  type: string
                  maxLength: 100
                  description: |
                    Name of the legal entity paying for the ad.
                    Required by Meta when targeting EU users (DSA Article 26).
                    Note Meta API spelling: dsa_payor (not dsa_payer).
      responses:
        '201':
          description: Ad created
          content:
            application/json:
              schema:
                type: object
                properties:
                  ad: { $ref: '#/components/schemas/Ad' }
                  message: { type: string }
        '400':
          description: Missing required fields or invalid values
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads add-on required
        '422':
          description: Platform ads connection required (TikTok Ads, X Ads) or missing linked account

  /v1/ads/create:
    post:
      operationId: createStandaloneAd
      tags: [Ads]
      summary: Create standalone ad
      description: >-
        Creates a paid ad with custom creative across Meta, Google Ads,
        Pinterest, TikTok, and X/Twitter. Supports three mutually-exclusive
        request shapes selected by the body, a legacy single-creative shape
        (all platforms, default), a Meta-only multi-creative shape via the
        creatives array (one ad set with N ads sharing budget and targeting),
        and a Meta-only attach shape via adSetId (adds one new ad to an
        existing ad set). Per-platform required fields, budget minimums, and
        video-ad rules (Meta only) are documented on each property below.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, adAccountId, name]
              properties:
                accountId: { type: string }
                adAccountId: { type: string }
                name: { type: string, maxLength: 255 }
                goal: { type: string, enum: [engagement, traffic, awareness, video_views, lead_generation, conversions, app_promotion], description: "Required on legacy + multi-creative shapes. Inherited from the ad set on the attach shape. Available goals vary by platform." }
                budgetAmount: { type: number, description: "Required on legacy + multi-creative shapes. Inherited on attach." }
                budgetType: { type: string, enum: [daily, lifetime], description: "Required on legacy + multi-creative shapes. Inherited on attach." }
                currency: { type: string }
                headline: { type: string, description: "Required for Meta, Google, and Pinterest on legacy + attach shapes (skip for multi-creative — use `creatives[].headline`). Ignored for TikTok and X/Twitter. Max: Meta=255, Google=30, Pinterest=100." }
                longHeadline: { type: string, maxLength: 90, description: "Google Display only. Defaults to `headline` if omitted." }
                body: { type: string, description: "Required on legacy + attach shapes. For X/Twitter this is the tweet text (max 280 chars including a ~24-char URL when `linkUrl` is set). Max: Google=90, Pinterest=500." }
                callToAction: { type: string, enum: [LEARN_MORE, SHOP_NOW, SIGN_UP, BOOK_TRAVEL, CONTACT_US, DOWNLOAD, GET_OFFER, GET_QUOTE, SUBSCRIBE, WATCH_MORE], description: "Required on legacy + attach shapes. Meta only." }
                linkUrl: { type: string, format: uri, description: "Required on legacy + attach shapes. Skip for multi-creative." }
                imageUrl: { type: string, format: uri, description: "Image creative for Meta/Google/Pinterest on legacy + attach shapes (mutually exclusive with `video`). Not required for Google Search campaigns. For TikTok, this field carries the VIDEO URL (the TikTok ads endpoint is video-only; the field retains the `imageUrl` name for cross-platform consistency). Ignored for X/Twitter. For Google Display, treated as the landscape image (alias of `images.landscape`); supply `images.square` alongside or the request is rejected." }
                images:
                  type: object
                  description: "Google Display (Responsive Display Ads) only. Google RDA requires both a landscape (1.91:1) and a square (1:1) marketing image; sending only one is rejected upstream as 'Too few.' (NOT_ENOUGH_*_MARKETING_IMAGE_ASSET). Supply both URLs here. Either this field or the legacy `imageUrl` can provide the landscape, but `square` has no legacy counterpart so it must be set here for Display."
                  properties:
                    landscape: { type: string, format: uri, description: "Landscape 1.91:1 marketing image URL (e.g. 1200x628). Also accepted via the top-level `imageUrl` for backward compatibility." }
                    square: { type: string, format: uri, description: "Square 1:1 marketing image URL (e.g. 1080x1080). Required for Google Display." }
                video:
                  type: object
                  description: "Meta only (facebook, instagram). When set, creates a VIDEO ad on the legacy or attach shape. Mutually exclusive with `imageUrl`. For multi-creative, set `video` per entry inside `creatives[]` instead."
                  required: [url, thumbnailUrl]
                  properties:
                    url: { type: string, format: uri, description: "Public URL of the video. Uploaded to Meta via chunked transfer on /act_X/advideos; then the request blocks on Meta's transcoding until status.video_status === 'ready'." }
                    thumbnailUrl: { type: string, format: uri, description: "Public URL of a still-image thumbnail for the video. Required by Meta on every video creative. Uploaded to Meta as an ad image and referenced as the thumbnail in object_story_spec.video_data." }
                creatives:
                  type: array
                  minItems: 1
                  description: |
                    Meta-only. When present, switches to the multi-creative shape:
                    creates 1 campaign + 1 ad set + N ads (one per entry here).
                    Top-level `headline` / `body` / `imageUrl` / `linkUrl` /
                    `callToAction` are ignored in this mode. Mutually exclusive with `adSetId`.
                  items:
                    type: object
                    required: [headline, body, linkUrl, callToAction]
                    description: "Each creative must supply EXACTLY ONE of `imageUrl` (image creative) or `video` (video creative)."
                    properties:
                      headline: { type: string, maxLength: 255 }
                      body: { type: string }
                      imageUrl: { type: string, format: uri, description: "Image creative. Mutually exclusive with `video`." }
                      video:
                        type: object
                        description: "Video creative for this entry. Mutually exclusive with `imageUrl`."
                        required: [url, thumbnailUrl]
                        properties:
                          url: { type: string, format: uri }
                          thumbnailUrl: { type: string, format: uri }
                      linkUrl: { type: string, format: uri }
                      callToAction: { type: string, enum: [LEARN_MORE, SHOP_NOW, SIGN_UP, BOOK_TRAVEL, CONTACT_US, DOWNLOAD, GET_OFFER, GET_QUOTE, SUBSCRIBE, WATCH_MORE] }
                adSetId:
                  type: string
                  description: |
                    Meta-only. When present, switches to the attach shape: adds
                    one new ad to this existing ad set without creating a new
                    campaign. Budget, targeting, goal, schedule, AND bid strategy
                    are inherited from the ad set on Meta — passing `bidStrategy`
                    in attach mode returns 400. To change an existing ad set's
                    bid, use `PUT /v1/ads/ad-sets/{adSetId}`. Mutually exclusive
                    with `creatives[]`.

                    Supported on Meta (facebook, instagram) and TikTok. On TikTok
                    the `adSetId` is the ad group ID; the new ad inherits the
                    ad group's bid + budget + targeting.
                businessName: { type: string, maxLength: 25, description: "Google Display only" }
                boardId: { type: string, description: "Pinterest only. Board ID (auto-creates if not provided)." }
                countries: { type: array, items: { type: string } }
                ageMin: { type: integer, minimum: 13, maximum: 65 }
                ageMax: { type: integer, minimum: 13, maximum: 65 }
                interests:
                  type: array
                  description: "Interest objects from /v1/ads/interests. Each must include id and name."
                  items:
                    type: object
                    required: [id, name]
                    properties:
                      id: { type: string }
                      name: { type: string }
                endDate: { type: string, format: date-time, description: Required for lifetime budgets }
                audienceId: { type: string, description: Custom audience ID for targeting }
                campaignType: { type: string, enum: [display, search], default: display, description: Google only }
                keywords: { type: array, items: { type: string }, description: Google Search only }
                additionalHeadlines: { type: array, items: { type: string }, description: "Google Search RSA only. Extra headlines." }
                additionalDescriptions: { type: array, items: { type: string }, description: "Google Search RSA only. Extra descriptions." }
                advantageAudience: { type: integer, enum: [0, 1], description: "Meta only. Controls the Advantage audience feature (targeting_automation). 0 = disabled (default), 1 = enabled. Meta Marketing API requires this field on all ad set creation requests." }
                gender: { type: string, enum: [all, male, female], default: all, description: "Meta only. Restrict the audience by gender. 'male' targets men only, 'female' targets women only, 'all' (default) targets everyone. Ignored by non-Meta platforms." }
                bidStrategy:
                  allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
                  description: "Meta bid strategy applied to the ad set."
                bidAmount:
                  type: number
                  description: |
                    Bid cap in WHOLE currency units (USD: 5 = $5.00; JPY: 100 = ¥100). Required when
                    `bidStrategy` is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`.
                roasAverageFloor:
                  type: number
                  description: |
                    Minimum ROAS as a decimal multiplier (e.g. 2.0 = 2.0x ROAS). Required when
                    `bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`. Sent to Meta as
                    `bid_constraints.roas_average_floor` × 10000.
                dsaBeneficiary:
                  type: string
                  maxLength: 100
                  description: |
                    Name of the legal entity benefiting from the ad.
                    Required by Meta when targeting EU users (DSA Article 26).
                    Not enforced at schema level; enforced server-side when targeting intersects EU member states.
                dsaPayor:
                  type: string
                  maxLength: 100
                  description: |
                    Name of the legal entity paying for the ad.
                    Required by Meta when targeting EU users (DSA Article 26).
                    Note Meta API spelling: dsa_payor (not dsa_payer).
      responses:
        '201':
          description: Ad(s) created
          content:
            application/json:
              schema:
                oneOf:
                  - type: object
                    description: "Legacy + attach shapes — one ad returned."
                    properties:
                      ad: { $ref: '#/components/schemas/Ad' }
                      message: { type: string }
                  - type: object
                    description: "Multi-creative shape — N ads returned sharing platformCampaignId / platformAdSetId."
                    properties:
                      ads:
                        type: array
                        items: { $ref: '#/components/schemas/Ad' }
                      platformCampaignId: { type: string }
                      platformAdSetId: { type: string }
                      message: { type: string }
        '400':
          description: Missing required fields, invalid values, or non-Meta platform used with creatives[] / adSetId
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads add-on required
        '422':
          description: Platform ads connection required (TikTok Ads, X Ads) or missing linked account

  /v1/ads/interests:
    get:
      operationId: searchAdInterests
      tags: [Ads]
      summary: Search targeting interests
      description: Search for interest-based targeting options available on the platform.
      security:
        - bearerAuth: []
      parameters:
        - { name: q, in: query, required: true, schema: { type: string }, description: Search query }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: Social account ID }
      responses:
        '200':
          description: Matching interests
          content:
            application/json:
              schema:
                type: object
                properties:
                  interests:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        category: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads add-on required

  /v1/ads/audiences:
    get:
      operationId: listAdAudiences
      tags: [Ad Audiences]
      summary: List custom audiences
      description: Returns custom audiences for the given ad account. Supports Meta, Google, TikTok, and Pinterest.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: Social account ID }
        - { name: adAccountId, in: query, required: true, schema: { type: string }, description: Platform ad account ID }
        - { name: platform, in: query, schema: { type: string, enum: [facebook, instagram, googleads, tiktok, pinterest] } }
      responses:
        '200':
          description: Audiences
          content:
            application/json:
              schema:
                type: object
                properties:
                  audiences:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, nullable: true }
                        platformAudienceId: { type: string }
                        name: { type: string }
                        description: { type: string }
                        type: { type: string, enum: [customer_list, website, lookalike] }
                        platform: { type: string }
                        size: { type: integer }
                        status: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads add-on required
    post:
      operationId: createAdAudience
      tags: [Ad Audiences]
      summary: Create custom audience
      description: Create a customer list, website retargeting, or lookalike audience on Meta (Facebook/Instagram).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, adAccountId, name, type]
              properties:
                accountId: { type: string }
                adAccountId: { type: string, description: "Must start with act_" }
                name: { type: string, maxLength: 255 }
                description: { type: string }
                type: { type: string, enum: [customer_list, website, lookalike] }
                pixelId: { type: string, description: Required for website audiences }
                retentionDays: { type: integer, minimum: 1, maximum: 180, description: Required for website audiences }
                sourceAudienceId: { type: string, description: Required for lookalike audiences }
                country: { type: string, description: "2-letter code, required for lookalike audiences" }
                ratio: { type: number, minimum: 0.01, maximum: 0.20, description: Required for lookalike audiences }
                rule: { type: object, description: "Pixel event rule for website audiences (optional)" }
                customerFileSource: { type: string, description: "Data source declaration for GDPR compliance (customer_list only)" }
      responses:
        '201':
          description: Audience created
          content:
            application/json:
              schema:
                type: object
                properties:
                  audience: { type: object }
                  message: { type: string }
        '400':
          description: Missing required fields
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads add-on required

  /v1/ads/audiences/{audienceId}:
    get:
      operationId: getAdAudience
      tags: [Ad Audiences]
      summary: Get audience details
      description: Returns the local audience record and fresh data from Meta (if available).
      security:
        - bearerAuth: []
      parameters:
        - { name: audienceId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Audience details
          content:
            application/json:
              schema:
                type: object
                properties:
                  audience: { type: object }
                  metaData: { type: object, nullable: true, description: Fresh data from Meta API }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads add-on required
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: deleteAdAudience
      tags: [Ad Audiences]
      summary: Delete custom audience
      description: Deletes the audience from both Meta and the local database.
      security:
        - bearerAuth: []
      parameters:
        - { name: audienceId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Audience deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads add-on required
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/ads/audiences/{audienceId}/users:
    post:
      operationId: addUsersToAdAudience
      tags: [Ad Audiences]
      summary: Add users to audience
      description: Upload user data (emails and/or phone numbers) to a customer_list audience. Data is SHA256-hashed server-side before sending to Meta. Max 10,000 users per request.
      security:
        - bearerAuth: []
      parameters:
        - { name: audienceId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [users]
              properties:
                users:
                  type: array
                  maxItems: 10000
                  items:
                    type: object
                    properties:
                      email: { type: string, format: email }
                      phone: { type: string }
                    description: Each user must have at least email or phone
      responses:
        '200':
          description: Users added
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  numReceived: { type: integer }
                  numInvalid: { type: integer }
        '400':
          description: Invalid input (empty users array, missing email/phone)
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads add-on required
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Audience is not a customer_list type or has no platform ID yet

  /v1/ads/conversions:
    post:
      operationId: sendConversions
      tags: [Ads]
      summary: Send conversion events to an ad platform
      description: |
        Relay one or more conversion events to the target ad platform's native
        Conversions API. Supported platforms: Meta (metaads) via Graph API,
        Google Ads (googleads) via Data Manager API `ingestEvents`.

        Platform is inferred from the provided `accountId`. `destinationId`
        semantics differ per platform:
        - Meta: pixel (dataset) ID, e.g. "123456789012345"
        - Google: conversion action resource name, e.g.
          "customers/1234567890/conversionActions/987654321"

        Callers can list valid destinations via
        `GET /v1/accounts/{accountId}/conversion-destinations`.

        All PII (email, phone, names, external IDs) is hashed with SHA-256
        server-side per each platform's normalization spec (including Google's
        Gmail-specific dot/plus-suffix stripping). Send plaintext.

        Requires the Ads add-on.

        Batching: Meta caps at 1000 events per request and rejects the entire
        batch if any event is malformed. Google caps at 2000. Both are handled
        automatically by chunking.

        Dedup: pass a stable `eventId` on every event. Meta uses it to dedupe
        against pixel events; Google maps it to transactionId.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, destinationId, events]
              properties:
                accountId:
                  type: string
                  description: SocialAccount ID (metaads or googleads).
                destinationId:
                  type: string
                  description: |
                    Platform destination identifier. For Meta, the pixel/dataset
                    ID. For Google, the conversion action resource name.
                events:
                  type: array
                  minItems: 1
                  items: { $ref: '#/components/schemas/ConversionEvent' }
                testCode:
                  type: string
                  description: Meta `test_event_code` passthrough. Ignored by Google.
                consent:
                  type: object
                  description: |
                    Batch-level user consent. Required by Google for EEA/UK
                    events under the Feb 2026 restrictions. Ignored by Meta.
                  properties:
                    adUserData: { type: string, enum: [GRANTED, DENIED] }
                    adPersonalization: { type: string, enum: [GRANTED, DENIED] }
      responses:
        '200':
          description: |
            Events processed. Inspect `eventsFailed` and `failures[]` to detect
            partial failure. For Meta, a batch is all-or-nothing (either every
            event in a chunk succeeds, or every event in the chunk is listed
            in failures). For Google, the API returns success/failure at the
            request level only.
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [metaads, googleads] }
                  eventsReceived: { type: integer, description: Events accepted by the platform. }
                  eventsFailed: { type: integer, description: Events rejected (see failures). }
                  failures:
                    type: array
                    items:
                      type: object
                      properties:
                        eventIndex: { type: integer, description: Index into the submitted events array. }
                        eventId: { type: string, description: Echoes back the eventId of the failed event. }
                        message: { type: string }
                        code: { oneOf: [{ type: string }, { type: integer }] }
                  traceId:
                    type: string
                    description: Platform trace ID (fbtrace_id for Meta, requestId for Google) for debugging.
        '400':
          description: Invalid body (missing accountId/destinationId/events, malformed event shape).
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads add-on required.
        '404':
          description: Account not found or not accessible.

  /v1/accounts/{accountId}/conversion-destinations:
    get:
      operationId: listConversionDestinations
      tags: [Ads]
      summary: List destinations for the Conversions API
      description: |
        Returns the list of pixels (Meta) or conversion actions (Google)
        accessible to the connected ads account. Use the returned `id` as
        `destinationId` when posting to `POST /v1/ads/conversions`.

        For Google, each destination's `type` reflects the conversion action's
        category (PURCHASE, LEAD, SIGN_UP, etc.) — the event type is locked to
        the destination. For Meta, `type` is absent: pixels accept any event
        name per request.
      security:
        - bearerAuth: []
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
          description: SocialAccount ID (metaads or googleads).
      responses:
        '200':
          description: Destinations listed
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [metaads, googleads] }
                  destinations:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          description: |
                            Destination identifier. Meta: pixel ID. Google:
                            conversion action resource name.
                        name: { type: string }
                        type:
                          type: string
                          description: |
                            Present when the platform locks event type to the
                            destination (Google conversion actions).
                        status: { type: string, enum: [active, inactive] }
        '400':
          description: Account's platform is not supported by the Conversions API.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads add-on required.
        '404':
          description: Account not found or not accessible.

  /v1/whatsapp/conversions:
    post:
      operationId: sendWhatsAppConversion
      tags: [WhatsApp, Ads]
      summary: Send WhatsApp conversion event
      description: |
        Forward a WhatsApp Business Messaging conversion event (`LeadSubmitted`,
        `Purchase`, `AddToCart`, `InitiateCheckout`, `ViewContent`) to Meta's
        Conversions API with `action_source = business_messaging` and
        `messaging_channel = whatsapp`. The endpoint looks up the originating
        CTWA click ID (`ctwa_clid`) captured on the first inbound message of
        the conversation and replays it on every event so Meta can attribute
        the conversion back to the Click-to-WhatsApp ad that drove the chat.

        Configuration prerequisites on the WhatsApp account metadata:
          - `metaCapiDatasetId`: the Meta Pixel/Dataset ID linked to the WABA.
          - `connectedFacebookPageId`: the Facebook Page paired with the
            WhatsApp Business number.

        Identify the conversation by either `conversationId` (preferred) or
        `phoneE164` (digits only, no `+`). At least one is required. If the
        conversation has no captured `ctwa_clid`, the request returns 422
        because there is nothing to attribute.

        Token and dataset coupling: the WhatsApp account's accessToken must
        have access to the configured `metaCapiDatasetId`. By default a WABA's
        system-user token is scoped to the WABA's own Business Manager and
        cannot post to a pixel owned by a different Business; Meta returns
        code 100 in that case. Either share the dataset with the WhatsApp
        app's Business in BM, or use a dataset already in the same Business
        as the WABA.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, eventName, eventId]
              description: |
                In addition to the `required` list, at least one of
                `conversationId` or `phoneE164` must be supplied (used to
                resolve the originating CTWA conversation). The route enforces
                this at the Zod boundary; OpenAPI's `required` cannot express
                OR-required cleanly.
              properties:
                accountId:
                  type: string
                  minLength: 1
                  description: WhatsApp SocialAccount ID.
                eventName:
                  type: string
                  enum: [LeadSubmitted, Purchase, AddToCart, InitiateCheckout, ViewContent]
                  description: |
                    Live-verified allowlist of event names accepted by Meta's
                    CAPI for Business Messaging (Graph API v25.0). Other
                    standard pixel events including `Lead`,
                    `CompleteRegistration`, `Subscribe`, `Schedule`, `Contact`,
                    `StartTrial`, `AddPaymentInfo`, `Search`, and
                    `SubmitApplication` are rejected with subcode 2804066
                    ("Messaging Event Invalid Event Type") on
                    `action_source = business_messaging` events. Custom event
                    names are also rejected.

                    Use `LeadSubmitted` (NOT `Lead`) for lead-style conversions.
                eventTime:
                  type: number
                  description: |
                    Unix seconds. Defaults to the time of the request when
                    omitted. Meta's attribution window is 7 days from click;
                    events older than that lose attribution.
                eventId:
                  type: string
                  minLength: 1
                  description: |
                    Stable dedup key. Reuse to suppress duplicate events
                    (Meta dedupes against pixel events with the same id).
                conversationId:
                  type: string
                  minLength: 1
                  description: |
                    Zernio Conversation `_id` (preferred lookup). The
                    conversation must have a captured `ctwa_clid` in metadata
                    (set automatically by the WhatsApp webhook on the first
                    inbound message after a CTWA ad click).
                phoneE164:
                  type: string
                  minLength: 1
                  description: |
                    Contact phone number, digits only with no '+'. When used
                    in lieu of `conversationId`, the handler resolves to the
                    most recent CTWA-attributed conversation for this phone
                    on the supplied account.
                value:
                  type: number
                  description: Conversion value (e.g. order total).
                currency:
                  type: string
                  minLength: 3
                  maxLength: 3
                  description: ISO 4217 currency code (e.g. `USD`).
                contentIds:
                  type: array
                  items: { type: string }
                  description: Optional product / content identifiers.
                email:
                  type: string
                  format: email
                  description: User email. Normalized + SHA-256 hashed before sending to Meta.
                externalId:
                  type: string
                  description: |
                    Stable customer identifier. Lowercased + SHA-256 hashed
                    before sending to Meta.
                testCode:
                  type: string
                  description: |
                    Meta `test_event_code` passthrough. Routes the event to
                    the Test Events tab in Events Manager instead of the
                    production dataset, useful for development.
      responses:
        '200':
          description: |
            Event submitted to Meta. Inspect `eventsFailed` and `failures[]`
            to detect partial failures. A 200 does not mean Meta accepted the
            event; the status reflects "request reached Meta" only.
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [metaads] }
                  eventsReceived:
                    type: integer
                    description: Events accepted by Meta.
                  eventsFailed:
                    type: integer
                    description: Events rejected by Meta (see failures).
                  failures:
                    type: array
                    description: |
                      Per-event failure detail. Empty when all events were
                      accepted.
                    items:
                      type: object
                      properties:
                        eventIndex:
                          type: integer
                          description: Index into the submitted events array.
                        eventId:
                          type: string
                          description: Echoes back the eventId of the failed event.
                        message: { type: string }
                        code:
                          oneOf:
                            - { type: string }
                            - { type: integer }
                  traceId:
                    type: string
                    description: |
                      Meta `fbtrace_id` for debugging. Surface in support
                      tickets.
        '400': { description: Invalid body. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Conversation not found. }
        '422':
          description: |
            Configuration missing (no `metaCapiDatasetId` /
            `connectedFacebookPageId` on the account) OR the resolved
            conversation has no captured `ctwa_clid`.

  /v1/ads/ctwa:
    post:
      operationId: createCtwaAd
      tags: [Ads]
      summary: Create Click-to-WhatsApp ad
      description: >-
        Creates a Click-to-WhatsApp (CTWA) ad on Meta. When tapped, the ad
        opens a WhatsApp conversation with the business attached to the
        supplied Facebook Page, and the full hierarchy (campaign, ad set,
        creative, ad) is created and activated in one call. The CTA is locked
        to WHATSAPP_MESSAGE and the destination is hard-coded to
        api.whatsapp.com/send; Meta resolves the actual WhatsApp number from
        the Page-to-WA pairing configured in Page settings or Business
        Manager. Prerequisites enforced by Meta (surfaced as platform_error on
        failure), the Facebook Page must be paired with a verified WhatsApp
        Business number, the WhatsApp Business Account must be
        business-verified, and the Meta access token must carry ads_management.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, adAccountId, name, headline, body, budgetAmount, budgetType]
              description: |
                In addition to the `required` list, exactly one of `imageUrl`
                or `video` must be supplied (they are mutually exclusive). The
                route enforces this at the Zod boundary; OpenAPI's `required`
                cannot express OR-required cleanly.
              properties:
                accountId:
                  type: string
                  minLength: 1
                  description: Facebook or Instagram SocialAccount ID.
                adAccountId:
                  type: string
                  minLength: 1
                  description: Meta ad account ID, e.g. `act_123456789`.
                name:
                  type: string
                  minLength: 1
                  description: Ad display name. Used to derive campaign / ad set names.
                headline:
                  type: string
                  minLength: 1
                  maxLength: 255
                body:
                  type: string
                  minLength: 1
                  description: Primary text shown above the image / video.
                imageUrl:
                  type: string
                  format: uri
                  description: |
                    Image asset for image creatives. Mutually exclusive with
                    `video`. Required if `video` is not supplied.
                video:
                  type: object
                  required: [url, thumbnailUrl]
                  properties:
                    url: { type: string, format: uri }
                    thumbnailUrl:
                      type: string
                      format: uri
                      description: |
                        Required by Meta for every video creative. Used as the
                        ad thumbnail.
                  description: |
                    Video creative. Mutually exclusive with `imageUrl`.
                    Required if `imageUrl` is not supplied.
                budgetAmount:
                  type: number
                  minimum: 0
                  exclusiveMinimum: true
                  description: |
                    Budget amount in the ad account's currency major units
                    (e.g. dollars for USD, not cents). Must be > 0.
                budgetType:
                  type: string
                  enum: [daily, lifetime]
                currency:
                  type: string
                  minLength: 3
                  maxLength: 3
                  description: |
                    ISO 4217 currency code matching the ad account's currency
                    (e.g. `USD`). Optional; Meta infers from the ad account
                    when omitted.
                endDate:
                  type: string
                  format: date-time
                  description: |
                    ISO 8601 datetime. Required when `budgetType` is `lifetime`.
                countries:
                  type: array
                  items: { type: string, minLength: 2, maxLength: 2 }
                  description: ISO 3166-1 alpha-2 country codes. Defaults to `["US"]`.
                ageMin: { type: integer, minimum: 13, maximum: 65 }
                ageMax: { type: integer, minimum: 13, maximum: 65 }
                interests:
                  type: array
                  items:
                    type: object
                    required: [id]
                    properties:
                      id: { type: string }
                      name: { type: string }
                audienceId:
                  type: string
                  description: Custom audience ID to target.
                advantageAudience:
                  type: integer
                  enum: [0, 1]
                  description: |
                    Meta's Advantage+ audience expansion. `0` (default) keeps
                    targeting strict; `1` lets Meta expand beyond the supplied
                    targeting when its delivery system finds better matches.
                    Always sent on CREATE (Meta requires it).
                objective:
                  type: string
                  enum: [OUTCOME_ENGAGEMENT, OUTCOME_SALES, OUTCOME_LEADS]
                  description: |
                    Defaults to `OUTCOME_ENGAGEMENT` (the broadly-supported CTWA
                    objective). `OUTCOME_SALES` and `OUTCOME_LEADS` require
                    additional account configuration (Dataset linked to the WABA
                    for sales) and may be rejected by Meta if missing.
                dsaBeneficiary:
                  type: string
                  maxLength: 100
                  description: |
                    Name of the legal entity benefiting from the ad.
                    Required by Meta when targeting EU users (DSA Article 26).
                    Not enforced at schema level; enforced server-side when targeting intersects EU member states.
                dsaPayor:
                  type: string
                  maxLength: 100
                  description: |
                    Name of the legal entity paying for the ad.
                    Required by Meta when targeting EU users (DSA Article 26).
                    Note Meta API spelling: dsa_payor (not dsa_payer).
      responses:
        '201':
          description: CTWA ad created and submitted to Meta for review.
          content:
            application/json:
              schema:
                type: object
                properties:
                  ad: { type: object, description: The persisted Ad document. }
                  message: { type: string }
        '400': { description: Invalid body. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Ads add-on required. }
        '404': { description: SocialAccount not found. }
        '422':
          description: Page is not connected to a verified WhatsApp number.
        '502':
          description: |
            Meta rejected the request (e.g. WABA business verification
            missing). Inspect `platformError` for the upstream Meta payload.
