openapi: 3.1.0
info:
  title: Zernio API
  version: "1.0.4"
  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, so Google Business posts always report `syncStatus: "unavailable"` with an explanatory `errorMessage`. Location-level metrics (impressions, clicks, calls, directions, bookings) are available via the dedicated `/v1/analytics/googlebusiness/performance` endpoint.

    ## Rate Limits

    API request throughput is rate-limited per minute on a sliding window. Limits scale with your team's total connected social accounts:

    - **0–2 accounts** (free tier): 60 req/min
    - **3–2,000 accounts**: 600 req/min
    - **2,001+ accounts**: 1,200 req/min

    Legacy AppSumo lifetime tiers get a flat 600 req/min regardless of tier.

    Posts themselves are unlimited on every connected social account; the rate limit applies only to API request throughput. Per-platform safety caps still apply at the social-network side (Instagram 100/day per account, Twitter 20/day per account, Pinterest 25/day per account).

    All responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers — read these instead of hard-coding limits, since your tier may be configured higher than the default.

    ## 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)
    - `post.platform.published` - A single platform target inside a post finished publishing successfully. Fires once per platform-account as it terminates, without waiting for other platforms on the same post. Use this for incremental UIs; use `post.published` for the post-level rollup.
    - `post.platform.failed` - A single platform target inside a post failed permanently. Temporary/retryable failures do NOT fire this event, only permanent ones, so retry loops stay quiet.
    - `post.tiktok.url_resolved` - A published TikTok post's public URL became available. TikTok exposes the numeric video id asynchronously, so `post.published` / `post.platform.published` can carry an empty `publishedUrl` for TikTok; this event delivers the URL once resolved (at most once per platform target). Never fires for drafts or private posts, which have no public URL.
    - `post.external.created` - A native post authored outside Zernio (e.g. a Google Business Profile localPost created in the Google UI) was detected by our sync for the first time. Poll-driven (~hourly), not real-time. Payload carries `post.source: "external"`.
    - `post.external.updated` - A tracked native post's text or media changed on the platform. Edits are detected by comparing text/media structure and (where the platform exposes it) the platform's own edit timestamp; media-URL-only refreshes do not fire this.
    - `post.external.deleted` - A tracked native post was detected as removed from the platform. `post.deletedAt` carries the detection time. Coverage is bounded to the most recent posts the platform listing returns.
    - `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)
    - `reaction.received` - A participant added or removed an emoji reaction on a message (WhatsApp, Telegram)
    - `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)
    - `lead.received` - New lead submitted against a Meta Lead Gen (Instant) Form. `lead.fields` is the question-key → answer map; `lead.formId`/`lead.adId` give provenance.
    - `ad.status_changed` - Ad, ad set, or campaign changed status on the ad platform (Meta: `in_process_ad_objects` entry/exit and `with_issues_ad_objects` violations)
    - `whatsapp.template.status_updated` - WhatsApp Business template completed (re)review by Meta. `template.status` carries the new state (APPROVED, REJECTED, PENDING, PAUSED, DISABLED, IN_APPEAL, PENDING_DELETION); `template.reason` is Meta's free-form reason or "NONE".
    - `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
    description: |
      Create, schedule, list, update, and delete posts across all connected social accounts.
  - name: Users
    description: |
      Read the authenticated user and team members.
  - name: Usage
    description: |
      Usage and metering, hub + spokes. `GET /v1/usage` is the hub: plan, billing period,
      and quota usage in one snapshot (`/v1/usage-stats` is its deprecated alias). The
      `/v1/usage/<domain>` spokes (calls, sms) meter consumption over any window with a
      uniform contract: since/until/number/groupBy in, totals + groups out.
  - name: Profiles
    description: |
      Manage profiles (named groups of social accounts).
  - name: Accounts
    description: |
      Manage connected social media accounts: list, fetch, update, disconnect, and read account health.
  - name: Account Groups
    description: |
      Manage account groups (collections of accounts used for cross-posting and organization).
  - name: API Keys
    description: |
      Create, list, and revoke API keys used to authenticate requests.
  - name: Invites
    description: |
      Generate invite tokens for adding members to a team.
  - name: Connect
    description: |
      OAuth and credential flows for connecting social accounts, plus per-platform selection
      steps (Facebook pages, Pinterest boards, LinkedIn organizations, GMB locations, etc.).
  - name: Media
    description: |
      Upload and presign media (images, videos, documents) for use in posts.
  - name: Reddit Search
    description: |
      Search Reddit posts and browse subreddit feeds.
  - name: GMB Reviews
    description: |
      Google Business Profile reviews: list reviews and reply to them.
  - name: GMB Food Menus
    description: |
      Read and update Google Business Profile food menus.
  - name: GMB Location Details
    description: |
      Read and update Google Business Profile location details.
  - name: GMB Media
    description: |
      Manage Google Business Profile media (photos and videos): list, upload, and delete.
  - name: GMB Attributes
    description: |
      Read and update Google Business Profile attributes.
  - name: GMB Place Actions
    description: |
      Manage Google Business Profile place action links (booking, ordering, reservations, etc.).
  - name: Discord
    description: |
      Discord-specific endpoints for managing webhook identity (display name and avatar), switching channels, and listing guild channels.
  - name: LinkedIn Mentions
    description: |
      Resolve LinkedIn organization and person mentions for use in posts.
  - name: Instagram
    description: |
      Instagram-specific read endpoints: list a connected account's Stories and fetch
      per-Story insights. All endpoints require an accountId parameter identifying the
      Instagram-connected social account.
  - name: Queue
    description: |
      Manage posting-queue time slots and preview the upcoming queue.
  - name: Analytics
    description: |
      Post and account analytics across platforms (insights, demographics, follower history,
      best time to post, content decay, and aggregated metrics).
  - 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: Mentions
    description: |
      Unified inbox API for managing mentions across connected accounts.
      Currently supports LinkedIn organization mentions.
      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: Workflows
    description: |
      Branching conversation automations. An inbound message matches a workflow's trigger and walks a
      directed graph of nodes (send message, wait for reply, condition, set variable, delay, webhook,
      handoff, end). Unlike Sequences (linear, time-based drips), Workflows are event-driven and
      interactive. Fully supported on WhatsApp, Instagram, and Messenger; `send_message` template and
      interactive modes are WhatsApp-only.
  - name: Comment Automations
    description: |
      Comment-to-DM growth automations. Set up keyword triggers on Instagram/Facebook so
      commenters automatically receive a DM. Scope per post or account-wide (omit
      `platformPostId` to match comments on every post on the account, with unlimited
      automations stacked per account). 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: Tracking Tags
    description: |
      Manage the platform measurement tag — the thing you create, install on a website, send events to,
      and target ads against. On Meta this is a Pixel; the surface is platform-neutral so other platforms
      (Pinterest Tag, LinkedIn Insight Tag, etc.) can be added without changing the contract. Create a tag,
      get it (including the install code snippet), rename + adjust matching/cookie/data-use settings, share
      it with ad accounts, and read aggregated event stats. Currently Meta-only.
      Requires the Ads add-on.
  - name: Webhooks
    description: |
      Configure webhooks for real-time notifications. Webhooks can be created from the dashboard (Settings → Webhooks) or via this API.
      Events: post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled, post.platform.published, post.platform.failed, post.tiktok.url_resolved, post.external.created, post.external.updated, post.external.deleted, account.connected, account.disconnected, account.ads.initial_sync_completed, message.received, message.sent, message.edited, message.deleted, message.delivered, message.read, message.failed, reaction.received, comment.received, review.new, review.updated, lead.received, ad.status_changed, whatsapp.template.status_updated, whatsapp.number.activated, whatsapp.number.declined, whatsapp.number.action_required, whatsapp.number.verification_required, whatsapp.number.suspended, whatsapp.number.reactivated, whatsapp.number.released, whatsapp.number.kyc_submitted, 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 Calling
    description: |
      Voice calling over the WhatsApp Business API: enable/disable calling on a number,
      configure call hours and permissions, and place or list calls.
      All endpoints require an accountId parameter identifying the WhatsApp-connected social account.
  - name: WhatsApp Templates
    description: |
      Browse Meta's pre-approved WhatsApp template library. Use these read-only lookups to
      discover library templates you can import as your own message templates.
      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: Phone Numbers
    description: |
      Buy and manage phone numbers. A phone number is a platform-independent unit;
      WhatsApp, SMS, and Voice are features you enable on it. Purchase, search
      inventory, complete country KYC, and release numbers here; turn features on
      via the per-number sub-resources (`/v1/phone-numbers/{id}/voice`, `/sms`,
      `/whatsapp/...`). Requires a paid plan.
  - name: WhatsApp Phone Numbers
    description: |
      Deprecated namespace: phone numbers moved to `/v1/phone-numbers` (a number is
      platform-independent; WhatsApp is one feature on it). These aliases keep
      working with the same contracts, but new integrations should use the
      Phone Numbers endpoints.
  - name: WhatsApp Sandbox
    description: |
      Shared WhatsApp sandbox: a Zernio-owned WhatsApp number every user can test against
      without provisioning their own. Send the verified sandbox template to phones you
      activate via a reply-based verification flow. Designed for testing message flows,
      bot replies, and webhook payloads end-to-end with zero number-purchase overhead.
  - name: Calls
    description: |
      Unified call history across every number you own: WhatsApp Business Calling and
      regular phone (PSTN) calls in one list, newest first, without fanning out one
      request per number. Each row carries `channel` and `accountId` so you can fetch
      details and recordings from the matching channel-specific endpoint.
      Private beta: returns 403 unless your account is enrolled.
  - name: Voice
    description: |
      Regular phone (PSTN) calling on your numbers. Enable voice on a number and route
      inbound calls to your own AI voice agent (Vapi/Retell), a phone, or a SIP endpoint,
      with voicemail, business-hours routing, IVR, recording, and transcription. Place
      outbound calls, manage live ones (end, blind-transfer), resolve recordings, and
      drive the browser softphone.
      Private beta: returns 403 unless your account is enrolled.
  - name: SMS
    description: |
      SMS/MMS on your numbers: enable SMS on a number, send messages, validate recipient
      numbers, export STOP opt-outs, and complete the US carrier registration (10DLC or
      toll-free) required before US traffic delivers.
      Private beta: returns 404 unless your account is enrolled.
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
    PaymentRequired:
      description: |
        Payment method or enterprise contract required. The authenticated
        account hit a billing gate before the connection could proceed.
        Three reasons:

          - `free_tier_exceeded`: the team has connected more accounts
            than the free tier allows. Add a payment method on the
            dashboard to continue (the user will be billed per
            additional connected account).

          - `twitter_passthrough`: connecting an X (Twitter) account
            requires a card on file from day one because X API calls
            incur real per-call pass-through costs. Applies to the 1st
            X account, not just the 3rd+.

          - `enterprise_required`: the team has reached the public
            pricing ceiling (2,000 connected accounts). Beyond that
            requires a custom enterprise contract negotiated directly.
            `dashboard_url` deep-links to the enterprise contact page
            rather than the billing tab. The end-user already has a
            card on file; this gate is about contract terms, not card
            collection.

        SDK consumers should switch on `reason` to render the right
        prompt. For `free_tier_exceeded` and `twitter_passthrough`,
        redirect the end-user to `dashboard_url` to add a payment method
        via Zernio's hosted Stripe Setup Checkout. For
        `enterprise_required`, redirect to `dashboard_url` (the
        enterprise contact form) so they can talk to sales.
      content:
        application/json:
          schema:
            type: object
            required: [error, code, reason]
            properties:
              error:
                type: string
                description: Human-readable error message suitable for end-user display.
                example: 'X (Twitter) requires a payment method due to API pass-through costs. Add a payment method to connect an X account.'
              code:
                type: string
                enum: [PAYMENT_REQUIRED]
                description: Machine-readable error code. Stable across versions.
              reason:
                type: string
                enum: [free_tier_exceeded, twitter_passthrough, enterprise_required]
                description: Discriminator for which gate fired.
              documentation_url:
                type: string
                format: uri
                description: Link to the relevant documentation page.
                example: https://docs.zernio.com/billing/payment-method-required
              dashboard_url:
                type: string
                format: uri
                description: |
                  Deep-link to send the end-user to. For
                  `free_tier_exceeded` and `twitter_passthrough` this is
                  the Zernio billing tab. For `enterprise_required` this
                  is the Zernio enterprise contact page.
                example: https://zernio.com/dashboard?tab=billing
              details:
                type: object
                description: Structured context for SDK clients that want to render their own UX. Keys vary by `reason`.
                properties:
                  free_tier_account_limit:
                    type: integer
                    description: How many accounts the free tier allows. Only set when reason=free_tier_exceeded.
                    example: 2
                  current_account_count:
                    type: integer
                    description: How many accounts the team currently has connected. Set when reason=free_tier_exceeded or reason=enterprise_required.
                    example: 5
                  has_payment_method:
                    type: boolean
                    description: Whether the team currently has a card on file in Stripe. Set when reason=free_tier_exceeded or reason=twitter_passthrough.
                  public_account_limit:
                    type: integer
                    description: Public pricing ceiling (the published cap beyond which an enterprise contract is required). Only set when reason=enterprise_required.
                    example: 2000
                  effective_account_limit:
                    type: integer
                    description: |
                      The cap actually applied to this team. Equals
                      `public_account_limit` for organic teams; for teams
                      with a per-customer override (grandfathered legacy
                      customers, signed enterprise contracts) this can
                      be higher. Only set when reason=enterprise_required.
                    example: 2000
          examples:
            freeTierExceeded:
              summary: Free tier exceeded (no card on file)
              value:
                error: 'Add a payment method to connect more than 2 accounts.'
                code: PAYMENT_REQUIRED
                reason: free_tier_exceeded
                documentation_url: https://docs.zernio.com/billing/payment-method-required
                dashboard_url: https://zernio.com/dashboard?tab=billing
                details:
                  free_tier_account_limit: 2
                  current_account_count: 3
                  has_payment_method: false
            twitterPassthrough:
              summary: Connecting first X account without a card
              value:
                error: 'X (Twitter) requires a payment method due to API pass-through costs. Add a payment method to connect an X account.'
                code: PAYMENT_REQUIRED
                reason: twitter_passthrough
                documentation_url: https://docs.zernio.com/billing/payment-method-required
                dashboard_url: https://zernio.com/dashboard?tab=billing
                details:
                  has_payment_method: false
            enterpriseRequired:
              summary: Team has hit the public 2,000-account ceiling
              value:
                error: 'You have 2000 connected accounts. Beyond 2,000 requires an enterprise contract. Please contact sales to enable larger usage.'
                code: PAYMENT_REQUIRED
                reason: enterprise_required
                documentation_url: https://docs.zernio.com/billing/payment-method-required
                dashboard_url: https://zernio.com/enterprise
                details:
                  public_account_limit: 2000
                  effective_account_limit: 2000
                  current_account_count: 2000
  schemas:
    WorkflowNode:
      type: object
      required: [id, type]
      description: A node in a workflow graph. `config` shape depends on `type`.
      properties:
        id: { type: string, description: Stable node id referenced by edges }
        type:
          type: string
          description: >
            Node kind. The 16 supported types break into four groups:
              messaging (send_message),
              control flow (trigger, condition, delay, wait_for_reply, a_b_split, end),
              data ops (set_variable, set_field, add_tag, remove_tag, enroll_sequence),
              integrations (webhook, ai, handoff, start_call).
          enum:
            - trigger
            - send_message
            - wait_for_reply
            - condition
            - set_variable
            - delay
            - webhook
            - ai
            - handoff
            - start_call
            - a_b_split
            - set_field
            - enroll_sequence
            - add_tag
            - remove_tag
            - end
        config:
          type: object
          additionalProperties: true
          description: >
            Type-specific settings. All string fields support `{{variable}}` interpolation against the
            run's variable bag (resolved at execution time).


            **trigger**: `{ triggerType: inbound_message|api_call|whatsapp_event, keywords:[string],
            matchType: any|contains|exact|regex, onlyFirstMessage:boolean, eventType:
            message_sent|message_delivered|message_read|message_failed|reaction }`.
            Default `triggerType` is `inbound_message` for legacy nodes. `eventType` is only honored
            when `triggerType` is `whatsapp_event` (WhatsApp-only).


            **send_message**: `{ messageType: text|template|media|interactive, text,
            template:{name,language,variableMapping}, media:{mediaType:image|video|audio|document,
            url,caption}, interactive }`. `template` and `interactive` are WhatsApp-only.
            `interactive.type` is inferred from the payload shape when omitted; payloads with
            neither `type` nor an inferable shape are rejected.


            **wait_for_reply**: `{ timeoutMinutes:int (max 43200), saveAs:string }`. Resume via
            the `'reply'` edge on inbound, or `'timeout'` edge after `timeoutMinutes` of silence.


            **condition**: `{ rules:[{ id, variable, operator:
            equals|not_equals|contains|not_contains|starts_with|ends_with|exists|not_exists|matches,
            value }] }`. First matching rule takes its `id` as the sourceHandle; otherwise
            `'default'`.


            **set_variable**: `{ assignments:[{ name, value }] }`. Run-scoped (lives only for this
            execution; use `set_field` for persistent values).


            **delay**: `{ delayMinutes:int (max 43200) }`. Suspends the run, resumes via timer.


            **webhook**: `{ url, method: GET|POST|PUT|PATCH|DELETE, headers, bodyTemplate, saveAs }`.
            SSRF-guarded (private/loopback/metadata IPs rejected). Response saved as
            `{ status, ok, body }` to `vars[saveAs]`. Edge: `'success'` on 2xx, `'error'` otherwise.


            **ai**: `{ provider: anthropic|openai|google|mistral|groq, model, preset:
            smart|tools|cheap, systemPrompt, userPromptTemplate, saveAs, temperature, maxTokens,
            outputType: text|json, tools:[{ name, description, parameters }] }`. Set `provider` +
            `model` for BYOK (uses your stored API key); omit `provider` for the legacy Telnyx
            path. Edges: `'success'`, `'tool:<name>'` (model picked a tool), `'error'`.


            **handoff**: `{ note, assignTo }`. Terminates the run as `exited`, flags the
            conversation for a human operator.


            **start_call**: `{ to, forwardTo, requirePermissionFirst, recordingEnabled, saveAs }`.
            WhatsApp-only. `forwardTo` can be `tel:+E164`, `sip:user@host`, or `wss://…` (AI voice
            agent). Edges: `'success'`, `'permission_required'`, `'failed'`.


            **a_b_split**: `{ percentage: number 0-100 (default 50) }`. Random branch picker.
            Edges: `'a'` (with probability `percentage/100`), `'b'`.


            **set_field**: `{ field, value }`. Persistent custom field on the Contact (vs
            `set_variable` which is run-scoped). Field name is sanitized to `[A-Za-z0-9_]`.
            No-op on `api_call` runs (no contact).


            **enroll_sequence**: `{ sequenceId, saveAs }`. Enrolls the run's contact into a
            Sequence. Edges: `'success'`, `'error'`.


            **add_tag** / **remove_tag**: `{ tag }`. Push or pull a tag on the Contact. No-op on
            `api_call` runs.


            **end**: no config. Terminates the run as `completed`.
        position:
          type: object
          description: Canvas coordinates (ignored by the executor; used by the visual builder).
          properties:
            x: { type: number }
            y: { type: number }
    WorkflowEdge:
      type: object
      required: [id, source, target]
      description: A directed edge between two nodes.
      properties:
        id: { type: string }
        source: { type: string, description: Source node id }
        target: { type: string, description: Target node id }
        sourceHandle:
          type: [string, "null"]
          description: >
            Selects a branch output of a multi-output node. Null (or omitted) = the node's
            single/default output. Known handles per node type:

              - **condition** — a rule's `id`, or `'default'` (no rule matched)
              - **wait_for_reply** — `'reply'` (contact replied) | `'timeout'` (no reply in window)
              - **webhook** — `'success'` (2xx) | `'error'` (non-2xx / fetch failed)
              - **ai** — `'success'` (text/JSON response) | `'tool:<toolName>'` (model invoked
                that tool) | `'error'` (upstream failure / non-JSON in JSON mode)
              - **start_call** — `'success'` | `'permission_required'` | `'failed'`
              - **a_b_split** — `'a'` | `'b'`
              - **enroll_sequence** — `'success'` | `'error'`
    WorkflowExecutionEvent:
      type: object
      description: >
        One entry in a workflow execution's timeline. Emitted by the executor on every node
        visit and lifecycle transition, surfaced by `GET /v1/workflows/{workflowId}/executions/
        {executionId}/events` for run inspection in the Runs UI.
      properties:
        action:
          type: string
          enum:
            - execution_started
            - execution_completed
            - execution_exited
            - execution_paused
            - execution_resumed
            - node_started
            - node_completed
            - node_failed
            - node_skipped
        status: { type: [string, "null"], enum: [success, failed, pending] }
        nodeId: { type: [string, "null"], description: Present on `node_*` events }
        nodeType: { type: [string, "null"], description: Present on `node_*` events }
        sourceHandle:
          type: [string, "null"]
          description: The edge handle the executor followed out of this node (see `WorkflowEdge.sourceHandle`)
        durationMs: { type: [integer, "null"], description: Node run time; present on `node_completed` and `node_failed` }
        errorMessage: { type: [string, "null"], description: Failure detail; present on `node_failed` and `execution_exited` }
        meta:
          type: [object, "null"]
          additionalProperties: true
          description: >
            Per-node-type payload. Shape varies — see WorkflowNode `type`. Examples:
              `send_message` → `{ messageType, text, recipient }`,
              `webhook` → `{ url, method, statusCode, responseTimeMs, responsePreview }`,
              `ai` → `{ model, provider, inputTokens, outputTokens, responsePreview }`,
              `condition` → `{ matchedHandle, rulesEvaluated }`,
              `a_b_split` → `{ percentage, chosen }`.
        at: { type: string, format: date-time, description: Event timestamp (UTC) }
    BulkUploadResult:
      type: object
      description: |
        Result of a CSV bulk upload. The same shape is returned for `200` (all rows
        succeeded or all failed) and `207` (mixed). Per-row outcomes live in `results`;
        the row's success is `ok`, and failures carry machine-readable codes in `errors`.
      properties:
        total: { type: integer, description: "Number of data rows processed from the CSV" }
        valid: { type: integer, description: "Count of rows that succeeded (results[].ok === true)" }
        invalid: { type: integer, description: "Count of rows that failed (total - valid)" }
        results:
          type: array
          description: One entry per CSV data row, in row order.
          items:
            type: object
            properties:
              rowIndex: { type: integer, description: "1-based index of the CSV data row (header excluded)" }
              ok: { type: boolean, description: Whether the row was created successfully }
              createdPostId:
                type: string
                description: ID of the created post. Present only when `ok` is true and not a dry run.
              errors:
                type: array
                description: |
                  Machine-readable failure codes for this row. Present only when `ok` is false.
                  Examples: `unknown_profile:<id>`, `no_account_for_platform:<platform>`,
                  `schedule_time_missing`, `rate_limited:<platform>:@<username>:<remaining>`.
                items: { type: string }
        warnings:
          type: array
          description: "Top-level advisory warnings (e.g. `rows_exceed_advisory_limit:500`). Empty when none."
          items: { type: string }
        rateLimitedAccounts:
          type: array
          description: |
            Present only when one or more rows targeted an account currently in cooldown.
            Lets callers map `rate_limited:*` row errors back to structured metadata without
            parsing the error strings.
          items:
            type: object
            properties:
              accountId: { type: string }
              platform: { type: string }
              username: { type: string }
              rateLimitedUntil: { type: string, format: date-time }
    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, "null"], 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 }
    ExternalPostSummary:
      type: object
      description: |
        A post synced from a platform (published directly on the platform, not
        through Zernio). Returned by GET /v1/posts?source=external and
        POST /v1/posts/sync-external. Analytics are exposed separately via
        GET /v1/analytics?source=external.
      properties:
        platform:
          type: string
          description: Platform the post belongs to (e.g. instagram, youtube, tiktok)
        platformPostId:
          type: string
          description: The platform's own post/media/video id
        platformPostUrl:
          type: string
          description: Canonical URL (permalink) of the post on the platform
        content:
          type: string
          description: Post caption / text
        publishedAt:
          type: string
          format: date-time
          description: When the post was published on the platform
        mediaType:
          type: string
          description: Media type (e.g. image, video, carousel)
        mediaUrl:
          type: string
          description: Primary media URL
        thumbnailUrl:
          type: string
          description: Thumbnail URL
        mediaItems:
          type: array
          description: Per-item media (for carousels / multi-media posts)
          items:
            type: object
        analytics:
          type: object
          description: |
            Engagement + insights for the post. `likes` and `comments` are
            available immediately after an on-demand sync (they come from the
            platform listing). `reach`, `impressions`, `views` depend on the
            platform's insights, which carry their own delay (e.g. ~24h on
            Instagram) and read 0 until the platform makes them available.
          properties:
            likes: { type: integer }
            comments: { type: integer }
            shares: { type: integer }
            saves: { type: integer }
            sends: { type: integer }
            clicks: { type: integer }
            views: { type: integer }
            reach: { type: integer }
            impressions: { type: integer }
            engagementRate: { type: number }
            lastUpdated: { type: string, format: date-time, description: When these metrics were last refreshed }
    ErrorResponse:
      type: object
      description: |
        Canonical error envelope. `error` is the human-readable message; `type`,
        `code`, `param`, `platform`, and `platformError` are top-level siblings
        for programmatic handling. For upstream platform failures (`type:
        platform_error`), `platformError` carries the provider's raw payload
        verbatim (for Meta: `error_subcode`, `error_user_title`, `error_user_msg`).
      properties:
        error:
          type: string
          description: Human-readable error message.
        type:
          type: string
          enum: [invalid_request_error, authentication_error, permission_error, not_found, rate_limit_error, platform_error, api_error]
          description: Error class for programmatic handling.
        code:
          type: string
          description: Stable machine-readable error code.
        param:
          type: string
          description: The request field that caused the error, when applicable.
        platform:
          type: string
          description: "Upstream platform (e.g. meta, google, tiktok) — present when type is platform_error."
        platformError:
          type: object
          additionalProperties: true
          description: |
            Raw error payload from the upstream platform, passed through verbatim so
            integrators can read provider-specific codes. For Meta this includes
            error_subcode, error_user_title, and error_user_msg.
        details:
          type: object
          additionalProperties: true
          description: Additional structured context (e.g. field-level validation errors).
    DmButton:
      type: object
      description: |
        A single inline button rendered inside an auto-DM via Meta's button_template.
        Up to 3 buttons per automation. `url` and `postback` work on Instagram and
        Facebook; `phone` is Facebook-only. When buttons are set, `dmMessage` becomes
        the button_template text and must be 640 characters or less.
      required: [type, title]
      properties:
        type: { type: string, enum: [url, postback, phone] }
        title: { type: string, maxLength: 20, description: Button label (20 chars max) }
        url: { type: string, format: uri, description: Target URL (required when type is url) }
        payload: { type: string, description: Postback payload delivered via the messaging_postbacks webhook (required when type is postback) }
        phone: { type: string, description: "Phone number, e.g. +14155551234 (required when type is phone; Facebook only)" }
    WhatsAppTemplateButton:
      type: object
      required: [type]
      properties:
        type:
          type: string
          enum: [quick_reply, url, phone_number, otp, copy_code, flow, mpm, catalog]
        text:
          type: string
          description: Visible button label. Required for all types except copy_code (whose label is fixed by WhatsApp).
        url:
          type: string
          format: uri
          description: Required when type is URL
        example:
          description: "Example value(s). Accepts either a string or an array of strings. For URL buttons, an array of URL-suffix samples; for copy_code (marketing) buttons, the coupon code as a single string. Left untyped (string | string[]) on purpose: a typed oneOf of string-vs-array breaks several SDK code generators."
        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'
    WhatsAppSandboxSession:
      type: object
      description: |
        A per-user activation session against the shared WhatsApp sandbox number.
        Transitions `pending → active` when the inbound webhook receives a reply
        from the matching phone (the reply itself proves ownership).
      required: [id, phoneE164, status, expiresAt]
      properties:
        id:
          type: string
          description: Session id. Use this to revoke via DELETE.
        phoneE164:
          type: string
          description: Digits-only E.164 form (no +, spaces, or dashes).
          example: "34688246216"
        status:
          type: string
          enum: [pending, active]
          description: |
            `pending` until the phone replies to the activation template, then
            `active`. Expired sessions are pruned by TTL and never appear in
            list responses.
        expiresAt:
          type: string
          format: date-time
          description: |
            UTC timestamp at which the session becomes invalid. Pending sessions
            get a 24h window; activated sessions get 7 days.
        activatedAt:
          type: [string, "null"]
          format: date-time
          description: When the session transitioned `pending → active`, or null.
        createdAt:
          type: [string, "null"]
          format: date-time
    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
        durationSeconds:
          type: [integer, "null"]
          description: Video length in seconds (from YouTube contentDetails.duration)
        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
              averageViewPercentage:
                type: number
                description: Average percentage of the video watched per view. Can exceed 100 on Shorts (looping rewatches), so do not clamp it client-side.
              subscribersGained:
                type: integer
              subscribersLost:
                type: integer
              likes:
                type: integer
              comments:
                type: integer
              shares:
                type: integer
        lastSyncedAt:
          type: [string, "null"]
          format: date-time
          description: When the data was last synced from YouTube
        scopeStatus:
          type: object
          properties:
            hasAnalyticsScope:
              type: boolean
    YouTubeVideoRetentionResponse:
      type: object
      properties:
        success:
          type: boolean
          example: true
        accountId:
          type: string
          description: The Zernio account ID for the YouTube account
        videoId:
          type: string
          description: The YouTube video ID
        title:
          type: [string, "null"]
          description: Video title
        publishedAt:
          type: [string, "null"]
          format: date-time
          description: When the video was published on YouTube
        durationSeconds:
          type: [integer, "null"]
          description: Video length in seconds (from YouTube contentDetails.duration)
        dateRange:
          type: object
          properties:
            startDate:
              type: string
              format: date
            endDate:
              type: string
              format: date
        retentionCurve:
          type: array
          description: Up to 100 points covering the video timeline, aggregated over the date range. Empty for videos with very few views.
          items:
            type: object
            properties:
              elapsedVideoTimeRatio:
                type: number
                description: Position in the video as a ratio (0.01-1.0, exclusive end of each interval)
              audienceWatchRatio:
                type: number
                description: Absolute share of viewers watching at this point. Can exceed 1 (rewinds/looping, common on Shorts).
              relativeRetentionPerformance:
                type: number
                description: Retention vs videos of similar length (0 = worst, 0.5 = median, 1 = best)
              startedWatching:
                type: integer
                description: Viewers who started watching in this segment
              stoppedWatching:
                type: integer
                description: Viewers who stopped watching in this segment
              totalSegmentImpressions:
                type: integer
                description: Total views of this segment, including rewatches
        note:
          type: string
          description: Present only when the curve is empty, explaining why
        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 verification.
        events:
          type: array
          items:
            type: string
            enum: [post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled, post.platform.published, post.platform.failed, post.tiktok.url_resolved, post.external.created, post.external.updated, post.external.deleted, account.connected, account.disconnected, account.ads.initial_sync_completed, message.received, message.sent, message.edited, message.deleted, message.delivered, message.read, message.failed, reaction.received, comment.received, review.new, review.updated, ad.status_changed, whatsapp.template.status_updated, whatsapp.number.activated, whatsapp.number.declined, whatsapp.number.action_required, whatsapp.number.verification_required, whatsapp.number.suspended, whatsapp.number.reactivated, whatsapp.number.released, whatsapp.number.kyc_submitted]
          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
    WebhookLog:
      type: object
      description: A single webhook delivery attempt recorded by Zernio (30-day retention).
      properties:
        userId:
          type: string
          description: ID of the account owner the webhook belongs to
        webhookId:
          type: string
          description: ID of the webhook configuration that produced this delivery
        webhookName:
          type: string
          description: Name of the webhook configuration at delivery time
        eventId:
          type: string
          description: Stable webhook event ID (correlates to the delivered payload)
        event:
          type: string
          description: Event type that triggered the delivery (e.g. post.published)
        url:
          type: string
          format: uri
          description: Destination URL the webhook was delivered to
        status:
          type: string
          enum: [success, failed]
          description: Delivery outcome
        statusCode:
          type: integer
          description: HTTP status code returned by the destination endpoint
        requestPayload:
          type: object
          additionalProperties: true
          description: The JSON payload sent to the destination endpoint
        responseBody:
          type: string
          description: Response body returned by the destination endpoint
        errorMessage:
          type: string
          description: Error message when delivery failed
        attemptNumber:
          type: integer
          description: Delivery attempt number (increments on retries)
        responseTime:
          type: integer
          description: Time taken by the destination endpoint to respond, in milliseconds
        createdAt:
          type: string
          format: date-time
          description: Timestamp the delivery was attempted
    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
                  accountId:
                    type: string
                    description: SocialAccount id this platform target published through. Use it to route events by connected account (e.g. separate staging vs production endpoints). A post can span multiple accounts.
                  platformPostId:
                    type: string
                  publishedUrl:
                    type: string
                  error:
                    type: string
        timestamp:
          type: string
          format: date-time
    WebhookPayloadPostPlatform:
      type: object
      description: |
        Webhook payload for the per-platform terminal events
        `post.platform.published` and `post.platform.failed`, and for
        `post.tiktok.url_resolved` (same shape, fired when a published
        TikTok post's public URL is backfilled). Terminal events fire once
        per platform target inside a post as that platform reaches a
        terminal state (published or permanent failure). The `post`
        envelope mirrors the shape of `WebhookPayloadPost` so consumers
        can reuse rendering logic; the `platform` block identifies which
        specific platform transitioned; the `account` block identifies
        the connected social account behind that platform-write.
      required: [id, event, post, platform, account, timestamp]
      properties:
        id:
          type: string
          description: Stable webhook event ID.
        event:
          type: string
          enum: [post.platform.published, post.platform.failed, post.tiktok.url_resolved]
        post:
          type: object
          required: [id, content, status, scheduledFor, platforms]
          properties:
            id: { type: string }
            content: { type: string }
            status:
              type: string
              description: |
                Post-level status AT FIRE TIME. May still be `publishing`
                if other platforms haven't terminated; check this field
                rather than assuming.
            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 }
                  accountId: { type: string, description: 'SocialAccount id this platform target published through. On post.platform.* events see also the top-level `account` block.' }
                  platformPostId: { type: string }
                  publishedUrl: { type: string }
                  error: { type: string }
        platform:
          type: object
          description: The specific platform that just transitioned to a terminal state.
          required: [name, status]
          properties:
            name:
              type: string
              description: Platform name (e.g. `twitter`, `tiktok`, `instagram`).
            status:
              type: string
              enum: [published, failed]
              description: Terminal status this event fires on. Matches the event suffix.
            platformPostId:
              type: string
              description: Platform-native post id. Present on `published`, absent on `failed`.
            publishedUrl:
              type: string
              description: Public URL to the platform-side post. Present on `published` (when the platform exposes one and it is not a draft).
            error:
              type: string
              description: Error message from the platform. Present on `failed`, absent on `published`.
        account:
          type: object
          description: The connected social account the platform-write went through.
          required: [accountId, platform, username]
          properties:
            accountId: { type: string }
            platform: { type: string }
            username: { type: string }
            displayName: { 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.
            platformAdAccountId:
              type: string
              description: |
                When the consumer scoped the connect call to a single ad account, this echoes
                that ID back so the webhook can be correlated to the originating connect
                request without consulting the consumer's DB. Meta uses the `act_*` shape.
              example: act_1330190928038136
            platformAdAccountIds:
              type: array
              description: |
                Every ad-account ID that the connected token could see at discovery time.
                Useful for "we synced ads from these accounts" UX without a follow-up API call.
                Empty array when the token had no ad-account visibility.
              items:
                type: string
              example: ["act_1330190928038136", "act_98765432101234"]
        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.
            error:
              type: string
              description: |
                Free-form error message from the platform (typically Meta's Marketing API).
                Truncated to ~2KB. Present when `status` is `failure` (and sometimes on `success`
                when discovery saw zero ad accounts). For UX branching prefer `errorCategory`;
                this field is for human display and debugging.
            errorCode:
              type: string
              description: Platform-native error code if parsed (e.g. Meta `190`, `10`, `200`).
            errorSubcode:
              type: string
              description: Platform-native error subcode if parsed.
            errorCategory:
              type: string
              enum: [token_invalid, permission_denied, no_ad_accounts, rate_limited, discovery_failed, unknown]
              description: |
                Stable category for UX branching. New values may be added; existing ones are
                stable. Mapping:
                  - `token_invalid`: access token is expired or revoked. Reconnect.
                  - `permission_denied`: token lacks required scope, or the user has no role
                    on the Business Manager that owns the ad account. Reconnect with full
                    permissions, or have an admin grant access.
                  - `no_ad_accounts`: token is valid but sees zero ad accounts. The user
                    needs to connect a Business Manager that owns ad accounts.
                  - `rate_limited`: platform throttled us. Sync will retry automatically.
                  - `discovery_failed`: any other platform-side failure. Inspect `error`.
                  - `unknown`: classifier could not categorize the failure.
        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, "null"]
              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, "null"]
            createdAt:
              type: string
              format: date-time
            isReply:
              type: boolean
              description: Whether this is a reply to another comment
            parentCommentId:
              type: [string, "null"]
              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).
            attachment:
              type: object
              description: |
                Facebook only. Present on graphic-only comments (sticker, GIF, photo) that
                carry no text. URLs are ephemeral and may expire for Meta platforms (oe= expiry),
                so fetch promptly. Instagram comments do not support attachments.
              required: [type]
              properties:
                type:
                  type: string
                  description: 'Attachment type: sticker, animated_image_share, or photo.'
                imageUrl:
                  type: string
                  description: Rendered image/preview URL (from attachment.media.image.src).
                url:
                  type: string
                  description: 'Source URL (from attachment.url). For GIFs this is an l.facebook.com redirect.'
        post:
          type: object
          required: [id, platformPostId]
          properties:
            id:
              type: [string, "null"]
              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
            accountId:
              type: string
              description: Social account ID (same as id); canonical field for account filtering.
            platform:
              type: string
            username:
              type: string
        timestamp:
          type: string
          format: date-time
    WebhookPayloadLead:
      type: object
      description: Webhook payload for lead.received events (Meta Lead Gen / Instant Forms).
      required: [id, event, lead, account, timestamp]
      properties:
        id:
          type: string
          description: Stable webhook event ID
        event:
          type: string
          enum: [lead.received]
        lead:
          type: object
          required: [id, leadgenId, formId, fields, isOrganic, createdAt]
          properties:
            id:
              type: string
              description: Zernio lead ID (AdLead document ID)
            leadgenId:
              type: string
              description: Meta lead ID (the platform's leadgen_id)
            formId:
              type: string
              description: Lead Gen form ID the lead was submitted against
            formName:
              type: [string, "null"]
              description: Human-readable form name (best-effort; may be null)
            adId:
              type: [string, "null"]
              description: Meta ad ID that drove the lead (null for organic/test leads)
            adsetId:
              type: [string, "null"]
            campaignId:
              type: [string, "null"]
            fields:
              type: object
              additionalProperties:
                type: string
              description: >
                Flattened question key -> answer map. For multiple-choice questions
                the value is the option key (e.g. "k1"), not the display label.
            isOrganic:
              type: boolean
              description: True when the lead came from an organic post rather than a paid ad
            createdAt:
              type: string
              format: date-time
              description: Meta's lead creation time (ISO 8601)
        account:
          type: object
          required: [id, platform]
          properties:
            id:
              type: string
              description: Social account ID (the facebook account owning the Page)
            accountId:
              type: string
              description: Social account ID (same as id); canonical field for account filtering.
            platform:
              type: string
              enum: [facebook]
        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, "null"]
              description: Platform reviewer ID. Null when the platform does not expose it (common on Google Business anonymous reviews).
            name:
              type: string
            profileImage:
              type: [string, "null"]
        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
            accountId:
              type: string
              description: Social account ID (same as id); canonical field for account filtering.
            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
            accountId:
              type: string
              description: Social account ID (same as id); canonical field for account filtering.
            platform:
              type: string
            username:
              type: string
        timestamp:
          type: string
          format: date-time
    ExternalPostWebhookPost:
      type: object
      description: Native (external) post data shared by all post.external.* payloads.
      required: [id, platform, accountId, url, content, mediaType, mediaItems, thumbnailUrl, publishedAt, source]
      properties:
        id:
          type: string
          description: Platform-native post ID (NOT a Zernio post ID).
        platform:
          type: string
          description: Platform the post lives on (e.g. "googlebusiness").
        accountId:
          type: string
          description: Zernio social account ID the post belongs to.
        url:
          type: [string, "null"]
          description: Direct URL to the post on the platform, when available.
        content:
          type: string
          description: Post text. May be empty.
        mediaType:
          type: string
          description: One of image, video, gif, document, text, carousel.
        mediaItems:
          type: array
          items:
            $ref: '#/components/schemas/ExternalPostMediaItem'
        thumbnailUrl:
          type: [string, "null"]
        publishedAt:
          type: string
          format: date-time
        source:
          type: string
          enum: [external]
          description: Always "external" — distinguishes these from Zernio-originated post.* events.
        deletedAt:
          type: [string, "null"]
          format: date-time
          description: Detection time of deletion. Present on post.external.deleted; null/absent otherwise.
    WebhookPayloadExternalPost:
      type: object
      description: |
        Webhook payload for post.external.created / post.external.updated /
        post.external.deleted. Fired by Zernio's background sync when it detects a
        natively-authored post (e.g. a Google Business Profile localPost created in
        the Google UI), NOT a post published through Zernio. Poll-driven (~hourly),
        not real-time. On post.external.deleted, post.deletedAt is populated.
      required: [id, event, post, account, timestamp]
      properties:
        id:
          type: string
          description: Stable webhook event ID
        event:
          type: string
          enum: [post.external.created, post.external.updated, post.external.deleted]
        post:
          $ref: '#/components/schemas/ExternalPostWebhookPost'
        account:
          type: object
          required: [id, platform, username]
          properties:
            id:
              type: string
            accountId:
              type: string
              description: Social account ID (same as id); canonical field for account filtering.
            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, "null"]
          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.
            contactId:
              type: string
              description: |
                Zernio CRM Contact id for this sender, when one exists (joined via
                the ContactChannel mapping). Lets integrators link a message straight
                to a Contact without a follow-up Contacts API call. Omitted when the
                sender isn't a tracked contact (e.g. outgoing messages where the
                sender is the business, or first-touch messages before the contact
                is created).
            name:
              type: string
            username:
              type: string
            picture:
              type: string
            phoneNumber:
              type: [string, "null"]
              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, "null"] }
                isFollowing: { type: [boolean, "null"] }
                followerCount: { type: [integer, "null"] }
                isVerified: { type: [boolean, "null"] }
        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]
        contactId:
          type: string
          description: |
            Zernio CRM Contact ID for the participant, when one exists. Resolved by
            joining `participantId` to the ContactChannel collection. Best-effort:
            omitted when no channel matches or `participantId` is absent. Lets
            integrators join any inbox webhook back to the CRM Contact without
            needing to look at the sender — which matters for outgoing and
            delivery-status events whose sender is the business.
    InboxWebhookAccount:
      type: object
      description: The account context included in inbox webhook payloads.
      required: [id, platform, username]
      properties:
        id:
          type: string
          description: Social account ID
        accountId:
          type: string
          description: Social account ID (same value as id). Canonical field so consumers can filter every webhook event on one field (e.g. route staging vs production by account). id is kept for backward compatibility.
        profileId:
          type: string
          description: Zernio profile (workspace) ID this account belongs to. Use it to route or filter inbox webhooks by workspace. This is the profile ID only, not its name (resolve the name via the API with this ID). Optional; omitted on the shared WhatsApp sandbox account and when the account has no resolvable profile.
        platform: { type: string }
        username: { type: string }
        displayName: { type: string }
    # ────────────────────────────────────────────────────────────────────────
    WebhookPayloadReaction:
      type: object
      description: Webhook payload for reaction received events (WhatsApp, Telegram)
      required: [id, event, reaction, conversation, account, timestamp]
      properties:
        id:
          type: string
          description: Stable webhook event ID
        event:
          type: string
          enum: [reaction.received]
        reaction:
          type: object
          required: [emoji, action, platformMessageId, sender, reactedAt]
          properties:
            emoji:
              type: string
              description: |
                The emoji reacted with. May be an empty string when `action` is
                `removed` on WhatsApp (Meta does not report which emoji was removed).
            action:
              type: string
              enum: [added, removed]
            messageId:
              type: string
              description: Internal Zernio message ID of the reacted-to message, when resolvable from the platform ID.
            platformMessageId:
              type: string
              description: Platform-native ID of the reacted-to message (e.g. WhatsApp wamid).
            sender:
              type: object
              required: [id]
              description: The participant who added or removed the reaction.
              properties:
                id:
                  type: string
                contactId:
                  type: string
                  description: Zernio CRM Contact id for this sender, when one exists.
                name:
                  type: string
                username:
                  type: string
                picture:
                  type: string
                phoneNumber:
                  type: [string, "null"]
                  description: WhatsApp only. Sender's phone number in E.164 format (with leading `+`), when available.
            reactedAt:
              type: string
              format: date-time
        conversation:
          $ref: '#/components/schemas/InboxWebhookConversation'
        account:
          $ref: '#/components/schemas/InboxWebhookAccount'
        timestamp:
          type: string
          format: date-time
    # ────────────────────────────────────────────────────────────────────────
    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, "null"]
              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`.
                contactId:
                  type: string
                  description: Zernio CRM Contact id for this sender, when one exists (omitted for outgoing/business sender).
                name:
                  type: string
                username:
                  type: string
                picture:
                  type: string
                phoneNumber:
                  type: [string, "null"]
                  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, "null"]
                      description: Whether the sender follows your Instagram business account
                    isFollowing:
                      type: [boolean, "null"]
                      description: Whether your Instagram business account follows the sender
                    followerCount:
                      type: [integer, "null"]
                      description: The sender's follower count on Instagram
                    isVerified:
                      type: [boolean, "null"]
                      description: Whether the sender is a verified Instagram user
            sentAt:
              type: string
              format: date-time
            isRead:
              type: boolean
        conversation:
          $ref: '#/components/schemas/InboxWebhookConversation'
        account:
          $ref: '#/components/schemas/InboxWebhookAccount'
        metadata:
          type: [object, "null"]
          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.
            storyReply:
              type: object
              description: |
                Instagram only. Populated when an IG user replies to one of the
                account's stories (Meta `messaging_story_replies`). Mutually
                exclusive in practice with `isStoryMention`.
              required: [storyId]
              properties:
                storyId:
                  type: string
                  description: The Instagram story ID the user replied to.
                storyUrl:
                  type: string
                  description: |
                    Meta CDN URL for the story media. Expires approximately
                    24 hours after the story posted; consumers must fetch
                    promptly or treat 404s as expected.
            isStoryMention:
              type: boolean
              description: |
                Instagram only. True when the message was generated by an IG
                user mentioning the account in their own story (`story_mention`
                attachment type). Mutually exclusive in practice with `storyReply`.
            referral:
              type: [object, "null"]
              description: |
                Ad-click attribution forwarded verbatim from Meta. Populated only on
                the FIRST inbound message after the click; absent on subsequent
                messages of the same conversation.

                The populated subset identifies the source platform:
                  - `ctwa_clid` and `source_*` fields: WhatsApp CTWA
                    (Click-to-WhatsApp). Attribution window is 7 days from click.
                    Forward to Meta Conversions API for Business Messaging replay.
                  - `ad_id` and `ads_context_data`: Facebook Messenger CTM
                    (Click-to-Message) or Instagram CTD (Click-to-Direct). Use
                    `ad_id` to attribute the conversation to a specific ad.
              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
                ad_id:
                  type: string
                  description: |
                    Facebook Messenger CTM / Instagram CTD only. The Meta ad ID the
                    user clicked to start the conversation.
                ref:
                  type: string
                  description: |
                    Optional `ref` parameter passed through from the Meta ad
                    creative. Facebook Messenger CTM / Instagram CTD only.
                source:
                  type: string
                  description: |
                    Meta-supplied source identifier (e.g. `ADS`). Facebook Messenger
                    CTM / Instagram CTD only.
                type:
                  type: string
                  description: |
                    Meta-supplied referral type (e.g. `OPEN_THREAD`). Facebook
                    Messenger CTM / Instagram CTD only.
                ads_context_data:
                  type: object
                  description: |
                    Snapshot of the ad's public context at click time. Facebook
                    Messenger CTM / Instagram CTD only.
                  properties:
                    ad_title:
                      type: string
                    photo_url:
                      type: string
                    video_url:
                      type: string
                    post_id:
                      type: string
                    product_id:
                      type: string
                    flow_id:
                      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, "null"]
              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
                contactId:
                  type: string
                  description: Zernio CRM Contact id for this sender, when one exists.
                name:
                  type: string
                username:
                  type: string
                picture:
                  type: string
            sentAt:
              type: string
              format: date-time
            isRead:
              type: boolean
        conversation:
          $ref: '#/components/schemas/InboxWebhookConversation'
        account:
          $ref: '#/components/schemas/InboxWebhookAccount'
        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, "null"]
              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, "null"]
          description: Populated only on message.failed.
          properties:
            code: { type: integer }
            title: { type: string }
            message: { type: string }
            explanation:
              type: [string, "null"]
              description: |
                Plain-language translation of `code` (e.g. for 131026, that the
                recipient has likely opted out of marketing messages while utility
                templates are unaffected). Null for unmapped codes; fall back to
                title/message.
        conversation:
          $ref: '#/components/schemas/InboxWebhookConversation'
        account:
          $ref: '#/components/schemas/InboxWebhookAccount'
        timestamp: { type: string, format: date-time }
    WebhookPayloadConversationStarted:
      type: object
      description: |
        Fired once when a new conversation begins, in either direction. A conversation
        starts the first time an account and a contact exchange a message on any DM
        platform (Instagram, Messenger/Facebook, Telegram, WhatsApp, Twitter, Reddit,
        Bluesky). Platform-agnostic — one subscription covers every DM platform.
      required: [id, event, conversation, account, startedAt, timestamp]
      properties:
        id: { type: string, description: Stable webhook event ID }
        event:
          type: string
          enum: [conversation.started]
        conversation:
          type: object
          required: [id, platform, platformConversationId, participantName, status]
          properties:
            id: { type: string, description: Internal conversation ID }
            platform:
              type: string
              enum: [instagram, facebook, telegram, whatsapp, twitter, reddit, bluesky]
            platformConversationId: { type: string }
            participantId: { type: string, description: "Contact's platform identifier (IGSID, PSID, wa_id, etc.)" }
            participantName: { type: string }
            participantUsername: { type: string, description: Contact's handle when the platform exposes one }
            participantPicture: { type: string }
            status:
              type: string
              enum: [active, archived]
            contactId:
              type: string
              description: |
                Zernio CRM Contact ID for the participant, when one exists. Resolved by
                joining `participantId` to the ContactChannel collection (same join
                used by message.*, reaction.received, and call.* webhooks). Best-effort:
                omitted when no channel matches or `participantId` is absent. Lets
                integrators seed the CRM straight from `conversation.started` without
                waiting for the first `message.*` event.
        account:
          $ref: '#/components/schemas/InboxWebhookAccount'
        startedAt:
          type: string
          format: date-time
          description: When the conversation document was created.
        timestamp: { type: string, format: date-time }
    CallRecord:
      type: object
      description: |
        One call on a number you own, either channel. `channel` tells you which
        lane it took: `whatsapp` (WhatsApp Business Calling) or `pstn` (a regular
        phone call). List endpoints omit `transcript`; use `lastTranscriptSnippet`
        for a preview and the detail endpoint for the full transcript.
      properties:
        _id: { type: string }
        accountId: { type: string, description: "Owning social account. The unified /v1/calls/{id} detail + recording endpoints work for any channel; the channel-specific endpoints remain for account-scoped access." }
        conversationId: { type: [string, "null"], description: "Inbox conversation with the counterparty, when one exists." }
        contactId: { type: [string, "null"], description: "CRM Contact for the counterparty, when resolved." }
        channel: { type: string, enum: [whatsapp, pstn] }
        direction: { type: string, enum: [inbound, outbound] }
        from: { type: string, description: "Caller number (E.164)." }
        to: { type: string, description: "Callee number (E.164)." }
        forwardTo: { type: [string, "null"], description: "Destination the call was routed to (tel:/sip:/wss:), snapshotted at routing time." }
        greeting: { type: [string, "null"], description: "Outbound PSTN only. Message spoken to the callee on answer, before the bridge." }
        status: { type: string, enum: [ringing, answered, ended, failed] }
        isVoicemail: { type: boolean, description: True when an inbound call went to voicemail. }
        amd: { type: boolean, description: Outbound answering-machine detection was requested for this call. }
        answeredMachine: { type: [boolean, "null"], description: "With `amd`, whether a machine (vs a human) answered." }
        forwardCallerId: { type: string, enum: [business, caller], description: Caller ID presented on the forwarded leg. }
        recordingEnabled: { type: boolean, description: "Effective flag for THIS call (number default + per-call override, resolved at create time)." }
        transcriptionEnabled: { type: boolean }
        transcriptionLanguage: { type: string, enum: [auto, en, es] }
        startedAt: { type: string, format: date-time }
        answeredAt: { type: [string, "null"], format: date-time }
        endedAt: { type: [string, "null"], format: date-time }
        transferredAt: { type: [string, "null"], format: date-time, description: "When the call was blind-transferred (POST /v1/voice/calls/{id}/transfer)." }
        durationSeconds: { type: integer }
        endReason: { type: string, enum: [hangup, no_answer, rejected, error] }
        hangupCause: { type: [string, "null"], description: "Raw carrier hangup cause behind endReason (e.g. normal_clearing, not_found, time_limit) — the actual motive when endReason is a coarse bucket." }
        sipHangupCause: { type: [string, "null"], description: "SIP response code that ended the call, when SIP-signalled (e.g. '403', '488'). The real failure reason for SIP legs." }
        callErrors:
          type: array
          description: Per-call failure log (dial failed, bridge failed, recording error).
          items:
            type: object
            properties:
              code: { type: integer }
              message: { type: string }
        recordingUrl: { type: [string, "null"], description: "May be expired. Resolve a fresh playable URL via GET /v1/calls/{id}/recording (any channel)." }
        lastTranscriptSnippet: { type: [string, "null"], description: "Most recent transcript segment, for list previews." }
        transcript:
          type: array
          description: Full transcript segments (detail endpoint only; omitted from lists).
          items:
            type: object
            properties:
              text: { type: string }
              confidence: { type: number }
              at: { type: string, format: date-time }
        billing:
          type: object
          properties:
            metaMinutes: { type: number }
            telnyxSeconds: { type: number }
            transcriptionSeconds: { type: number }
            transcriptionCostUSD: { type: number }
            metaCostUSD: { type: number, description: 'WhatsApp channel only. Meta per-minute charge, billed by Meta directly to your WABA. Display only; not billed by Zernio.' }
            telnyxCostUSD: { type: number }
            recordingCostUSD: { type: number }
            billableCostUSD: { type: number, description: 'Amount Zernio bills you = telephony leg + recording + transcription (excludes any Meta portion).' }
            totalCostUSD: { type: number, description: 'Full cost incl. any Meta portion you pay directly. Display only.' }
            currency: { type: string }
        createdAt: { type: string, format: date-time }
        updatedAt: { type: string, format: date-time }
    WebhookPayloadCallReceived:
      type: object
      description: |
        Webhook payload for the `call.received` event. Fires for both
        inbound (UIC) and outbound (BIC) calls; branch on
        `call.direction` to tell them apart.
      required: [id, event, call, account, timestamp]
      properties:
        id: { type: string, description: Stable webhook event ID }
        event:
          type: string
          enum: [call.received]
        call:
          type: object
          properties:
            id: { type: string, description: Internal Zernio Call doc id }
            metaCallId: { type: [string, "null"], description: "Meta wacid.* call id when known" }
            accountId: { type: string }
            phoneNumberId: { type: string, description: "Meta phone_number_id" }
            direction: { type: string, enum: [inbound, outbound] }
            from: { type: string, description: "Consumer wa_id / E.164" }
            to: { type: string, description: "Business number (E.164)" }
            forwardTo: { type: string, description: "Destination snapshot at routing time" }
            contactId: { type: string }
            conversationId: { type: string }
            startedAt: { type: string, format: date-time }
        account: { $ref: '#/components/schemas/InboxWebhookAccount' }
        timestamp: { type: string, format: date-time }
    WebhookPayloadCallEnded:
      type: object
      description: |
        Webhook payload for the `call.ended` event. Fires on call hangup
        with the duration and a zero-markup billing breakdown.
      required: [id, event, call, account, timestamp]
      properties:
        id: { type: string }
        event:
          type: string
          enum: [call.ended]
        call:
          type: object
          properties:
            id: { type: string }
            metaCallId: { type: [string, "null"] }
            accountId: { type: string }
            phoneNumberId: { type: string }
            direction: { type: string, enum: [inbound, outbound] }
            from: { type: string }
            to: { type: string }
            startedAt: { type: string, format: date-time }
            endedAt: { type: string, format: date-time }
            durationSeconds: { type: integer }
            endReason: { type: string, enum: [hangup, no_answer, rejected, error] }
            recordingUrl: { type: string }
            recordingExpiresAt: { type: string, format: date-time }
            billing:
              type: object
              properties:
                metaCostUSD: { type: number, description: 'Meta per-minute charge. Billed by Meta DIRECTLY to your WhatsApp Business Account payment method (your separate Meta invoice). Zernio does NOT charge this. Display only.' }
                telnyxCostUSD: { type: number }
                recordingCostUSD: { type: number }
                billableCostUSD: { type: number, description: 'The amount Zernio bills you = Telnyx leg + recording. Excludes Meta (billed by Meta directly).' }
                totalCostUSD: { type: number, description: 'Full economic cost incl. the Meta portion you pay directly (Meta + Telnyx + recording). Display only, not the Zernio-billed amount.' }
        account: { $ref: '#/components/schemas/InboxWebhookAccount' }
        timestamp: { type: string, format: date-time }
    WebhookPayloadCallFailed:
      type: object
      description: |
        Webhook payload for the `call.failed` event. Fired when a call
        setup or in-progress call fails.
      required: [id, event, call, account, timestamp]
      properties:
        id: { type: string }
        event:
          type: string
          enum: [call.failed]
        call:
          type: object
          properties:
            id: { type: string }
            metaCallId: { type: [string, "null"] }
            accountId: { type: string }
            phoneNumberId: { type: string }
            direction: { type: string, enum: [inbound, outbound] }
            from: { type: string }
            to: { type: string }
            failedAt: { type: string, format: date-time }
            error:
              type: object
              properties:
                code: { type: integer }
                message: { type: string }
        account: { $ref: '#/components/schemas/InboxWebhookAccount' }
        timestamp: { type: string, format: date-time }
    WebhookPayloadCallPermissionRequest:
      type: object
      description: |
        Webhook payload for the `call.permission_request` event. Fires
        when a consumer accepts or rejects an interactive
        `call_permission_request` message.
      required: [id, event, permission, account, timestamp]
      properties:
        id: { type: string }
        event:
          type: string
          enum: [call.permission_request]
        permission:
          type: object
          properties:
            from: { type: string, description: "Consumer wa_id who replied" }
            response: { type: string, enum: [accept, reject] }
            isPermanent: { type: boolean }
            expirationTimestamp: { type: string, format: date-time, description: "Present only when temporary" }
            responseSource: { type: string, description: "Meta's response source, typically `user_action`" }
        account: { $ref: '#/components/schemas/InboxWebhookAccount' }
        timestamp: { type: string, format: date-time }
    WebhookPayloadAdStatusChanged:
      type: object
      description: |
        Webhook payload for the `ad.status_changed` event. Currently emitted
        only for Meta (`metaads`).

        Sourced from two Meta `ad_account` webhook fields:
          - `in_process_ad_objects` - the ad object finished processing and
            exited `IN_PROCESS`. `status.raw` carries Meta's `status_name`.
          - `with_issues_ad_objects` - the ad object entered `WITH_ISSUES`.
            `status.raw` is `WITH_ISSUES` and the `error` block is populated
            from Meta's `error_code` / `error_summary` / `error_message`.
      required: [id, event, account, adObject, status, timestamp]
      properties:
        id:
          type: string
          description: Stable webhook event ID
        event:
          type: string
          enum: [ad.status_changed]
        account:
          type: object
          description: The connected ad-platform account that owns the ad object.
          required: [accountId, profileId, platform, username]
          properties:
            accountId:
              type: string
              description: Internal Zernio account ID (same as used in /v1/accounts/{accountId}).
            profileId:
              type: string
              description: Internal Zernio profile ID this account belongs to.
            platform:
              type: string
              description: Ad platform identifier. Currently always `metaads`.
              example: metaads
            username:
              type: string
              description: Display username of the connected ad-platform account.
            displayName:
              type: string
              description: Human-readable display name of the account, when available.
        adObject:
          type: object
          description: The ad-platform object the status change applies to.
          required: [level, platformId, platformAdAccountId]
          properties:
            level:
              type: string
              enum: [CAMPAIGN, AD_SET, AD]
              description: Hierarchy level the status applies to. Mirrors Meta's `level`. Creative-level events are not forwarded.
            platformId:
              type: string
              description: |
                Platform-native ID of the campaign / ad set / ad. For Meta this is
                the bare numeric ID (e.g. `120244894077860689`).
              example: "120244894077860689"
            platformAdAccountId:
              type: string
              description: |
                Platform-native ad-account ID. For Meta this uses the `act_<id>`
                shape.
              example: act_2129800524463520
        status:
          type: object
          description: Status info. Branch on `status.raw` to handle each transition.
          required: [raw]
          properties:
            raw:
              type: string
              description: |
                Platform-native status string, forwarded verbatim. For Meta
                this is `status_name` from `in_process_ad_objects` (e.g.
                `ACTIVE`, `PAUSED`, `PENDING_REVIEW`, `ARCHIVED`, `DELETED`,
                `DISAPPROVED`), or `WITH_ISSUES` when sourced from
                `with_issues_ad_objects`. Not constrained by an `enum` — Meta
                may add new values.
              example: ACTIVE
        error:
          type: object
          description: |
            Optional. Present on most `WITH_ISSUES` events, carrying the
            platform's error diagnostics. May be absent on some `WITH_ISSUES`
            events (Meta does not always include diagnostics). Always absent
            for any other `status.raw` value. Always null-check before reading.
          required: [code]
          properties:
            code:
              type: string
              description: |
                Platform-native error code, forwarded verbatim. For Meta this
                is `error_code` as a string. Use as the stable discriminator —
                `summary` and `message` are localized.
              example: "2643001"
            summary:
              type: string
              description: |
                Short human-readable summary (Meta `error_summary`). Localized
                to the ad-account owner's Meta locale — display only, do not
                match on it.
              example: Ad Processing Error
            message:
              type: string
              description: |
                Full human-readable error message (Meta `error_message`).
                Localized — display only.
        timestamp:
          type: string
          format: date-time
          description: ISO-8601 timestamp the webhook was produced.
    WebhookPayloadWhatsAppTemplateStatusUpdated:
      type: object
      description: |
        Webhook payload for the `whatsapp.template.status_updated` event.
        Fired when Meta completes (re)review of a template attached to a
        connected WABA. Maps Meta's `message_template_status_update` field
        onto our event envelope.
      required: [id, event, account, template, timestamp]
      properties:
        id:
          type: string
          description: Stable webhook event ID
        event:
          type: string
          enum: [whatsapp.template.status_updated]
        account:
          type: object
          required: [accountId, profileId, platform, username]
          properties:
            accountId: { type: string }
            profileId: { type: string }
            platform: { type: string, enum: [whatsapp] }
            username: { type: string }
            displayName: { type: string }
        template:
          type: object
          required: [templateId, name, language, status, reason]
          properties:
            templateId:
              type: string
              description: Meta's `message_template_id`, returned as a string.
            name:
              type: string
              description: Meta's `message_template_name`.
            language:
              type: string
              description: Meta's `message_template_language` (e.g. `en_US`).
            status:
              type: string
              enum: [APPROVED, REJECTED, PENDING, PAUSED, DISABLED, IN_APPEAL, PENDING_DELETION]
              description: |
                New status. Forwarded verbatim from Meta's `event` field.
                `PENDING_DELETION` is the 24h-grace state after a delete
                request before the template is actually removed.
            reason:
              type: string
              description: |
                Meta's free-form reason for the transition. `"NONE"` on
                approval; an explanation string on rejection.
        timestamp:
          type: string
          format: date-time
          description: ISO-8601 timestamp the webhook was produced.
    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.
        altText:
          type: string
          description: "Accessibility alternative text for an image, applied on every platform that supports it: Instagram (feed images only, not Reels/Stories), Facebook, Threads, X/Twitter (max 1000 chars), LinkedIn, Bluesky, and Pinterest (max 500 chars). Ignored on platforms without alt-text support (TikTok, YouTube, Snapchat, Telegram, Reddit, Google Business, WhatsApp) and on video items where the platform does not accept it. Set once per image; the same value is sent to each selected platform."
        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
    ExternalPostMediaItem:
      type: object
      description: >
        A media item on a native (external/synced) post, as carried by post.external.*
        webhook payloads. Distinct from the richer MediaItem used for Zernio-authored
        posts: external items are always already-published (url required) and limited to
        image or video. Kept as a separate schema so the generated SDK model does not
        collide with MediaItem.
      required: [type, url]
      properties:
        type:
          type: string
          enum: [image, video]
        url:
          type: string
        thumbnail:
          type: string
    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
        isTrialReel:
          type: boolean
          description: >-
            Present and true only when this Instagram reel was launched as a
            Trial through Zernio (created with platformSpecificData.trialParams).
            Use it to segment trial reels in analytics. Note: Instagram's Graph
            API exposes no readable trial field, so this reflects creation-time
            intent only. It indicates the reel STARTED as a trial, not whether
            or when it graduated.
        trialGraduationStrategy:
          type: string
          enum: [MANUAL, SS_PERFORMANCE]
          description: >-
            Graduation strategy the trial reel was launched with. Present only
            when isTrialReel is true.
        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, "null"]
          minimum: 1
          description: Stop recycling after this many copies have been created. Send null on update to clear this limit.
          example: 5
        expireDate:
          type: [string, "null"]
          format: date-time
          description: Stop recycling after this date, regardless of count. Send null on update to clear this limit.
        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.
        quoteTweetId:
          type: string
          description: ID (or full status URL) of an existing tweet to quote-repost. The published tweet becomes a quote tweet of the target. Mutually exclusive with media and poll. X only permits quoting your own posts or posts you are mentioned in / part of the conversation thread of; quoting an arbitrary other account's post is rejected by X. Billed at the standard create rate ($0.015), unlike pasting a tweet URL into the text which is billed at the URL rate ($0.20). For threads, applies to the first tweet only.
        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'
        paidPartnership:
          type: boolean
          default: false
          description: When true, the post is labeled by X as a paid partnership / paid promotion. For threads, applies to the root tweet only. Field availability may depend on your X API access tier.
        madeWithAi:
          type: boolean
          default: false
          description: When true, the post is labeled by X as containing AI-generated media. Per X, this label is for AI-generated media, not AI-written text. For threads, applies to the root tweet only.
        sensitiveMedia:
          type: object
          description: Marks attached media with a sensitive-content warning. Applied to every media item in the post (requires media; ignored for text-only tweets). At least one flag must be true for the warning to be sent.
          properties:
            adultContent:
              type: boolean
              description: Content contains adult material.
            graphicViolence:
              type: boolean
              description: Content depicts graphic violence.
            other:
              type: boolean
              description: Content has other sensitive characteristics.
      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 a draft 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'
        carouselCards:
          type: array
          minItems: 2
          maxItems: 5
          description: >
            Renders the post as a multi-link carousel (organic Page post). When set, mediaItems
            must be provided with the same length and all items must be images (no videos).
            Each cards[i] adds the click-through link and headline for the image at mediaItems[i].
            Mutually exclusive with contentType=story|reel. Facebook display truncates name at
            ~35 chars and description at ~30 chars; longer strings are accepted but get truncated
            on render.
          items:
            type: object
            required: [link]
            properties:
              link:
                type: string
                format: uri
                description: Per-card click destination (required).
              name:
                type: string
                maxLength: 255
                description: Per-card headline (optional, ~35-char display).
              description:
                type: string
                maxLength: 255
                description: Per-card subhead (optional, ~30-char display).
        carouselLink:
          type: string
          format: uri
          description: >
            Optional top-level "See more" destination shown on the carousel end card. Defaults
            to the first card's link when omitted. Only used together with carouselCards.
      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). Carousel posts (carouselCards) render a 2-5 card
        multi-link post, images only, mutually exclusive with story/reel. 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.
        isAiGenerated:
          type: boolean
          default: false
          description: When true, the post is labeled by Instagram as containing AI-generated media. Per Meta, this self-disclosure label is for AI-generated media, not AI-written captions. Applies to feed posts, Reels, Stories, and carousels.
      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)
        reshareUrl:
          type: string
          description: "LinkedIn post link to repost (use the post's \"Copy link to post\" action), or a urn:li:share / urn:li:ugcPost / urn:li:groupPost URN. The published post becomes a quote-reshare: your content is shown as the commentary and the original post is embedded underneath (LinkedIn's \"repost with your thoughts\"). Mutually exclusive with media. Works on personal profiles and organization pages."
        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. Sufficient on its own: "brand_organic"
            ("Your Brand") implies isBrandOrganicPost and "brand_content" ("Branded Content",
            paid partnership) implies brandPartnerPromote, so you don't need to send the
            boolean flags separately. Branded content cannot be posted with privacyLevel
            SELF_ONLY.
        brandPartnerPromote:
          type: boolean
          description: |
            Whether the post promotes a brand partner (branded content / paid partnership).
            Only needed to disclose BOTH types at once (set it alongside
            commercialContentType "brand_organic"), or to override the value implied by
            commercialContentType.
        isBrandOrganicPost:
          type: boolean
          description: |
            Whether the post promotes the creator's own brand (brand organic). Only needed
            to disclose BOTH types at once (set it alongside commercialContentType
            "brand_content"), or to override the value implied by commercialContentType.
        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"
        flairText:
          type: string
          description: Custom flair text, for subreddits that allow free-text flair. Ignored when flairId is provided (flairId wins).
        nsfw:
          type: boolean
          default: false
          description: Mark the post as NSFW (Not Safe For Work / over 18).
        spoiler:
          type: boolean
          default: false
          description: Mark the post as a spoiler. The subreddit must have spoiler tagging enabled for this to take effect.
        sendreplies:
          type: boolean
          default: true
          description: Whether to receive inbox replies for comments on this post. Set to false to opt out.
        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.

    DiscordScheduledEvent:
      type: object
      description: |
        Discord guild scheduled event. Returned by /v1/discord/guilds/{guildId}/events endpoints.
        Fields below are the subset Zernio consumes — Discord may return more (e.g. creator,
        image hash) which we pass through verbatim.
      properties:
        id: { type: string, description: Event snowflake ID }
        guild_id: { type: string }
        channel_id: { type: [string, "null"], description: Voice/stage channel ID; null for external events. }
        creator_id: { type: [string, "null"] }
        name: { type: string }
        description: { type: [string, "null"] }
        scheduled_start_time: { type: string, format: date-time }
        scheduled_end_time: { type: [string, "null"], format: date-time, description: Required for external events; optional for voice/stage. }
        privacy_level: { type: integer, enum: [2], description: Always 2 (GUILD_ONLY) — Discord deprecated PUBLIC events. }
        status:
          type: integer
          enum: [1, 2, 3, 4]
          description: "1=SCHEDULED, 2=ACTIVE, 3=COMPLETED, 4=CANCELED"
        entity_type:
          type: integer
          enum: [1, 2, 3]
          description: "1=STAGE_INSTANCE, 2=VOICE, 3=EXTERNAL"
        entity_id: { type: [string, "null"] }
        entity_metadata:
          type: [object, "null"]
          properties:
            location: { type: string, description: External event location string. }
        user_count: { type: integer, description: Number of members who RSVP'd. Only present when withUserCount=true on list. }
        image: { type: [string, "null"], description: Cover image hash; build URL via cdn.discordapp.com. }

    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
      required: [_id, platform, profileId, isActive]
      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, "null"]
          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, "null"]
          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
      description: |
        Plan and usage stats. The response shape depends on `billingSystem`:
          * Stripe users (default): per-period counters like `usage.uploads` and
            `usage.profiles` are returned, scoped by the plan's `limits`.
          * Metronome users (usage-based): `limits` are unlimited (-1). The
            `usage` block carries connected-account and per-X-operation counts,
            and the `spend` block carries current-period costs plus the X cap.
      properties:
        billingSystem:
          type: string
          enum: [stripe, metronome]
          description: Which billing system the account is on. Shape of `usage`/`spend` differs.
        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" }
        hasAccess:
          type: boolean
          description: True if the account is in good standing. False for past-due/unpaid/paused subscriptions.
        customerId:
          type: [string, "null"]
          description: Stripe customer ID, when present.
        isInvitedUser:
          type: boolean
          description: True if this is a team member; limits/usage reflect the account owner.
        autoUpgradeEnabled:
          type: boolean
          description: Stripe-only. Always false for Metronome users.
        limits:
          type: object
          description: Plan limits. For Metronome users both fields are `-1` (unlimited).
          properties:
            uploads: { type: integer }
            profiles: { type: integer }
        usage:
          type: object
          description: |
            Per-period usage counts. Fields present depend on `billingSystem`:
            Stripe returns `uploads` / `profiles` / `lastReset`;
            Metronome returns `connectedAccounts` / `xApiCalls` / `xApiCallsByOperation`.
          properties:
            # Stripe fields
            uploads: { type: integer, description: "Stripe users only. Uploads consumed in the current period." }
            profiles: { type: integer, description: "Stripe users only. Profiles currently owned." }
            lastReset: { type: string, format: date-time, description: "Stripe users only." }
            # Metronome fields
            connectedAccounts:
              type: integer
              description: "Metronome users only. Accounts currently connected across the team."
            xApiCalls:
              type: object
              deprecated: true
              description: |
                **Deprecated.** Legacy 3-tier aggregate. Operations outside the
                three historical prices ($0.005/$0.010/$0.015) — notably the
                $0.200 "Posts with URL" tier added April 2026 — are silently
                excluded from this shape. Use `xApiCallsByOperation` instead;
                it captures every tier and is the source of truth for
                per-operation call counts.
              properties:
                x_api_005: { type: integer, description: "Calls at $0.005 per call (reads, lists, bookmarks, content manage, etc.)" }
                x_api_010: { type: integer, description: "Calls at $0.010 per call (user reads, DM reads, follow reads, trends, list create, privacy update)" }
                x_api_015: { type: integer, description: "Calls at $0.015 per call (posts/replies, DM sends, user interactions)" }
            xApiCallsByOperation:
              type: object
              additionalProperties:
                type: integer
              description: |
                Metronome users only. Per-operation X API call counts keyed by
                operation (e.g. `posts_read`, `content_create`,
                `content_create_with_url`). Resolve each key to price and metadata
                via `GET /v1/billing/x-pricing`. This is the canonical source —
                covers every price tier including the $0.200 URL tier that
                `xApiCalls` excludes.
              example:
                posts_read: 42
                content_create: 7
                content_create_with_url: 3
                dm_interaction_create: 1
        spend:
          type: object
          description: "Metronome users only. Current-period spend summary."
          properties:
            currentPeriodCents:
              type: integer
              description: Total current-period spend in cents (all products combined).
            creditsRemainingCents:
              type: integer
              description: Free-tier credit remaining in cents. Applied before any charge.
            xSpendCents:
              type: integer
              description: |
                Current-period X/Twitter API spend in cents, summed from
                `xApiCallsByOperation` × per-operation prices. Tier-agnostic
                (covers every price including the $0.200 URL tier). Rounded
                up for conservative enforcement against `xSpendLimitCents`.
            xSpendLimitCents:
              type: [integer, "null"]
              description: |
                Monthly X spend cap set by the account owner, or null if no cap.
                When current X spend hits this cap, analytics and inbox sync are
                auto-paused for X accounts. Publishing is never blocked by this cap.
    XApiPricing:
      type: object
      description: |
        Canonical X/Twitter API pricing table. Zernio passes X API costs through
        at exact rates with zero markup, so every call you make has a known per-unit
        price. Use this payload alongside `/v1/usage-stats` (which returns
        per-operation call counts via `xApiCallsByOperation`) to compute exact
        cost attribution by X action.
      properties:
        currency: { type: string, example: USD }
        markup: { type: string, example: "0%", description: "Always 0% — Zernio does not mark up X API rates." }
        source: { type: string, format: uri, example: "https://developer.x.com/#pricing" }
        lastVerified:
          type: string
          format: date
          description: Date the prices were last verified against X's published rates.
        tiers:
          type: array
          description: Rollup of operations grouped by their per-call price.
          items:
            type: object
            properties:
              tier:
                type: string
                description: |
                  Tier key derived from price (e.g. `x_api_005` for $0.005,
                  `x_api_200` for $0.200). The first three keys map to the
                  legacy `xApiCalls` aggregate; new tiers (e.g. `x_api_200`
                  for the URL tier added April 2026) are surfaced here but
                  not in the legacy shape.
                example: x_api_005
              pricePerCallUsd: { type: number, example: 0.005 }
              operationCount: { type: integer, example: 13 }
        operations:
          type: array
          description: Flat list of every X operation Zernio can perform, with its rate.
          items: { $ref: '#/components/schemas/XApiOperation' }
    XApiOperation:
      type: object
      description: A single X API operation with its per-call price and the Zernio platform methods that trigger it.
      properties:
        operation:
          type: string
          example: posts_read
          description: Internal operation key. Matches keys in `xApiCallsByOperation`.
        eventType:
          type: string
          example: x_posts_read
          description: Metronome `event_type` emitted when this operation runs.
        displayName:
          type: string
          example: "X API: Posts Read"
          description: Human-readable label shown on Metronome invoices.
        pricePerCallUsd:
          type: number
          example: 0.005
        pricePerCallCents:
          type: number
          example: 0.5
          description: Per-call price in cents. Fractional values are intentional.
        tier:
          type: string
          description: |
            Tier key derived from `pricePerCallUsd` (e.g. `x_api_005` for
            $0.005, `x_api_200` for $0.200). Useful for grouping operations
            by price in dashboards.
          example: x_api_005
        triggeredBy:
          type: array
          description: Zernio platform methods that emit this operation, with their metering rule.
          items:
            type: object
            properties:
              method:
                type: string
                example: getPostAnalytics
                description: Zernio platform method name.
              metering:
                type: string
                enum: [always, analytics_optin, inbox_optin, absorbed]
                description: |
                  When the method actually bills the user:
                    * `always` — every call is metered
                    * `analytics_optin` — only when the X account has analytics enabled
                    * `inbox_optin` — only when the X account has inbox sync enabled
                    * `absorbed` — Zernio eats the cost, never billed
    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 }
        igReelsAvgWatchTime: { type: integer, example: 0, description: 'Instagram Reels only: average watch time per play, in milliseconds. 0 for non-Reels media and other platforms.' }
        igReelsVideoViewTotalTime: { type: integer, example: 0, description: 'Instagram Reels only: total watch time including replays, in milliseconds. 0 for non-Reels media and other platforms.' }
        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, "null"], description: 'The native post ID on the platform (e.g. Instagram media ID, tweet ID)' }
        accountId: { type: string }
        accountUsername: { type: [string, "null"] }
        analytics:
          anyOf:
            - $ref: '#/components/schemas/PostAnalytics'
            - type: "null"
        syncStatus: { type: string, enum: [synced, pending, unavailable], description: 'Sync state of analytics for this platform' }
        platformPostUrl: { type: [string, "null"], format: uri }
        errorMessage: { type: [string, "null"], description: 'Error details when status is failed' }
    AnalyticsOverview:
      type: object
      properties:
        totalPosts: { type: integer }
        publishedPosts: { type: integer }
        scheduledPosts: { type: integer }
        lastSync: { type: [string, "null"], format: date-time }
        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, "null"], 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, "null"], format: date-time }
        analytics:
          $ref: '#/components/schemas/PostAnalytics'
        platformAnalytics:
          type: array
          items:
            $ref: '#/components/schemas/PlatformAnalytics'
        platform: { type: string }
        platformPostUrl: { type: [string, "null"], format: uri }
        isExternal: { type: boolean }
        syncStatus: { type: string, enum: [synced, pending, partial, unavailable], description: 'Overall sync state across all platforms' }
        message: { type: [string, "null"], description: 'Human-readable status message for pending, partial, or failed states' }
        thumbnailUrl: { type: [string, "null"], format: uri }
        mediaType: { type: [string, "null"], enum: [image, video, 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) }
    AnalyticsListResponse:
      type: object
      properties:
        overview:
          $ref: '#/components/schemas/AnalyticsOverview'
        posts:
          type: array
          items:
            type: object
            properties:
              _id: { type: string }
              latePostId: { type: [string, "null"], 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, "null"] }
              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, "null"]
          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, "null"]
          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'
    ProfileCreateResponse:
      type: object
      properties:
        message:
          type: string
        profile:
          $ref: '#/components/schemas/Profile'
    ProfileGetResponse:
      type: object
      properties:
        profile:
          $ref: '#/components/schemas/Profile'
    ProfileUpdateResponse:
      type: object
      properties:
        message: { type: string }
        profile:
          $ref: '#/components/schemas/Profile'
    ProfileDeleteResponse:
      type: object
      properties:
        message: { type: string }
    AccountsListResponse:
      type: object
      required: [accounts, hasAnalyticsAccess]
      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'
    FollowerStatsResponse:
      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 }
    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 }
    QueueSlotsResponse:
      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 }
    QueueUpdateResponse:
      type: object
      properties:
        success: { type: boolean }
        schedule:
          $ref: '#/components/schemas/QueueSchedule'
        nextSlots:
          type: array
          items: { type: string, format: date-time }
        reshuffledCount: { type: integer }
        skippedDailyLimit: { type: integer }
        isNewQueue: { type: boolean }
    QueueDeleteResponse:
      type: object
      properties:
        success: { type: boolean }
        deleted: { type: boolean }
        deletedCount: { type: integer }
        message: { type: string }
    QueuePreviewResponse:
      type: object
      properties:
        profileId: { type: string }
        queueId: { type: string }
        queueName: { 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 }
        queueId: { type: string, description: Queue ID this slot belongs to }
        queueName: { type: string, description: Queue name }
    # 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."
        videoPlayActions:
          type: integer
          description: "Meta video ads only (0 for non-video ads and other platforms), like all video* fields below. Number of times the video started playing (Meta `video_play_actions`), summed over the date range and across children at ad-set/campaign level."
        video30SecWatchedActions: { type: integer, description: "Views of at least 30 seconds (or to the end, for shorter videos). Meta `video_30_sec_watched_actions`." }
        videoThruplayWatchedActions: { type: integer, description: "ThruPlays (watched to completion, or at least 15 seconds). Meta `video_thruplay_watched_actions`." }
        videoP25WatchedActions: { type: integer, description: "Views reaching 25% of the video's length. With the other percentile fields, powers hook/hold/drop-off analysis (e.g. hook rate = videoP25WatchedActions / videoPlayActions). Meta `video_p25_watched_actions`." }
        videoP50WatchedActions: { type: integer, description: "Views reaching 50% of the video's length. Meta `video_p50_watched_actions`." }
        videoP75WatchedActions: { type: integer, description: "Views reaching 75% of the video's length. Meta `video_p75_watched_actions`." }
        videoP95WatchedActions: { type: integer, description: "Views reaching 95% of the video's length. Meta `video_p95_watched_actions`." }
        videoP100WatchedActions: { type: integer, description: "Views reaching 100% of the video's length. Meta `video_p100_watched_actions`." }
        videoAvgTimeWatchedActions:
          type: number
          description: "Average seconds watched per play (Meta `video_avg_time_watched_actions`). Aggregated over date ranges and across children as a play-weighted average (total watch time / total plays), never a plain average of averages."
        lastSyncedAt: { type: string, format: date-time, description: "Present on individual ads only, not on campaign aggregations" }
    AdDailyMetrics:
      description: |
        One day of metrics. Same fields as `AdMetrics` plus the `date` they
        apply to. Returned inside a node's `daily[]` when `GET /v1/ads/tree` is
        called with `timeIncrement=1`. Rate metrics (ctr/cpc/cpm/costPerConversion/
        roas/videoAvgTimeWatchedActions) are recomputed per day from that day's
        sums, so summing the additive fields across a node's `daily[]` reproduces
        its aggregated `metrics` total. Do NOT sum or plain-average
        `videoAvgTimeWatchedActions` across days: the range value is the
        play-weighted average of the daily values.
      allOf:
        - $ref: '#/components/schemas/AdMetrics'
        - type: object
          properties:
            date: { type: string, format: date, description: "Calendar day (YYYY-MM-DD) these metrics apply to." }
    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, "null"]
          description: |
            Number of advertisers reachable under this BC for the calling token.
            `null` when the BC asset walk returned empty or failed (typical for
            agency apps without full BC asset read scope) — distinct from `0`,
            which would imply the BC genuinely has no advertisers.
          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] }
    TargetingSpec:
      type: object
      description: |
        Normalized, platform-agnostic ad-targeting spec. Every field is optional, an
        empty object targets the platform's default broadest audience. Field names are
        camelCase and identical across `POST /v1/ads/create` (the `targeting` object),
        `POST /v1/ads/targeting/reach-estimate`, and `saved_targeting` audiences, so a
        spec resolved once can be reused verbatim.

        Entity ids (`regions[].key`, `cities[].key`, `zips[].key`, `metros[].key`,
        `interests[].id`, `behaviors[].id`) are the platform's opaque identifiers
        resolved via `GET /v1/ads/targeting/search`. A spec is therefore meaningful only
        for the platform it was built against, except the portable fields (`countries`,
        `ageMin`/`ageMax`, `gender`, `incomeTier`, `languages`) which carry across
        platforms. Fields a platform cannot honour are rejected at create time with
        `INVALID_FIELD_VALUE` naming the offending field (not silently dropped).
      properties:
        countries: { type: array, items: { type: string }, description: "ISO 3166-1 alpha-2 country codes (e.g. ['US'])." }
        regions:
          type: array
          description: "Region/state targeting. `key` is the platform location ID from /v1/ads/targeting/search?dimension=geo&geoType=region."
          items:
            type: object
            required: [key]
            properties:
              key: { type: string }
              name: { type: string }
        cities:
          type: array
          description: "City targeting. Optional `radius` + `distance_unit` extend beyond the city limits; both must be set together or both omitted. `radius` is only honoured on platforms whose capability map allows city radius (Meta)."
          items:
            type: object
            required: [key]
            properties:
              key: { type: string }
              name: { type: string }
              radius: { type: number, description: "Radius around the city. Requires distance_unit." }
              distance_unit: { type: string, enum: [mile, kilometer], description: "Required if radius is set." }
        zips:
          type: array
          description: "Postal/ZIP targeting. `key` is the platform's postal location ID (e.g. Meta `US:94304`). Supported on Meta, Google, TikTok, Pinterest, X."
          items:
            type: object
            required: [key]
            properties:
              key: { type: string }
              name: { type: string }
        metros:
          type: array
          description: "DMA / metro-area targeting. `key` is the platform's metro ID (e.g. Meta `DMA:807`)."
          items:
            type: object
            required: [key]
            properties:
              key: { type: string }
              name: { type: string }
        customLocations:
          type: array
          description: "Point-radius (lat/lng) targeting (Meta custom_locations / Google proximity). Honoured only where the capability map allows radius (Meta)."
          items:
            type: object
            required: [latitude, longitude, radius, distanceUnit]
            properties:
              latitude: { type: number, minimum: -90, maximum: 90 }
              longitude: { type: number, minimum: -180, maximum: 180 }
              radius: { type: number, description: "Positive radius around the point." }
              distanceUnit: { type: string, enum: [mile, kilometer] }
              name: { type: string }
              address: { type: string, description: "Optional label, sent to Meta as `address_string`. latitude/longitude take precedence for the pin location." }
        excludedLocations:
          type: object
          description: "Geo to exclude from the audience. Mirrors the inclusion geo shape: excluded cities can carry a radius catchment and excluded custom (lat/lng) pins are supported, both on Meta (excluded_geo_locations)."
          properties:
            countries: { type: array, items: { type: string } }
            regions:
              type: array
              items:
                type: object
                required: [key]
                properties:
                  key: { type: string }
                  name: { type: string }
            cities:
              type: array
              description: "Cities to exclude. Optional `radius` + `distance_unit` exclude a catchment around the city (both must be set together or both omitted); Meta honours the radius on excluded cities."
              items:
                type: object
                required: [key]
                properties:
                  key: { type: string }
                  radius: { type: number, description: "Radius around the excluded city. Requires distance_unit." }
                  distance_unit: { type: string, enum: [mile, kilometer], description: "Required if radius is set." }
            zips:
              type: array
              items:
                type: object
                required: [key]
                properties:
                  key: { type: string }
                  name: { type: string }
            places:
              type: array
              description: "Named points of interest to exclude. `key` from /v1/ads/targeting/search."
              items:
                type: object
                required: [key]
                properties:
                  key: { type: string }
            neighborhoods:
              type: array
              description: "Named neighbourhood areas to exclude. `key` from /v1/ads/targeting/search."
              items:
                type: object
                required: [key]
                properties:
                  key: { type: string }
            customLocations:
              type: array
              description: "Point-radius (lat/lng) pins to exclude (Meta excluded_geo_locations.custom_locations). Mirrors the inclusion customLocations shape."
              items:
                type: object
                required: [latitude, longitude, radius, distanceUnit]
                properties:
                  latitude: { type: number, minimum: -90, maximum: 90 }
                  longitude: { type: number, minimum: -180, maximum: 180 }
                  radius: { type: number, description: "Positive radius around the point." }
                  distanceUnit: { type: string, enum: [mile, kilometer] }
                  name: { type: string }
                  address: { type: string, description: "Optional label, sent to Meta as `address_string`. latitude/longitude take precedence for the pin location." }
        ageMin: { type: integer, minimum: 13, maximum: 100 }
        ageMax: { type: integer, minimum: 13, maximum: 100 }
        gender: { type: string, enum: [all, male, female], description: "Restrict by gender. 'all' (default) targets everyone." }
        incomeTier:
          type: string
          enum: [top_5, top_10, top_10_25, top_25_50]
          description: |
            Normalized household-income tier (ZIP/percentile based). Meta and TikTok
            express all four. Google maps only `top_10` (its INCOME_RANGE_90_UP); other
            tiers on Google, and any income tier on LinkedIn / X / Pinterest, are rejected.
            On Meta, income/zip targeting requires the relevant `specialAdCategories` to be
            unset (housing/employment/credit ads cannot use it).
        languages: { type: array, items: { type: string }, description: "Language codes (e.g. ['en'])." }
        interests:
          type: array
          description: "Interest entities from /v1/ads/targeting/search?dimension=interest. Each carries the platform's opaque id."
          items:
            type: object
            required: [id]
            properties:
              id: { type: string }
              name: { type: string }
        behaviors:
          type: array
          description: "Behaviour entities from /v1/ads/targeting/search?dimension=behavior. Supported on Meta and TikTok."
          items:
            type: object
            required: [id]
            properties:
              id: { type: string }
              name: { type: string }
        industries: { type: array, items: { type: string }, description: "LinkedIn B2B only. Industry URN id fragments." }
        companySizes: { type: array, items: { type: string }, description: "LinkedIn B2B only." }
        seniorities: { type: array, items: { type: string }, description: "LinkedIn B2B only." }
        jobFunctions: { type: array, items: { type: string }, description: "LinkedIn B2B only." }
        audienceInclude: { type: array, items: { type: string }, description: "Platform audience IDs to include." }
        audienceExclude: { type: array, items: { type: string }, description: "Platform audience IDs to exclude." }
    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, lead_conversion, conversions, app_promotion, catalog_sales], description: "Available goals vary by platform. Meta (Facebook/Instagram) supports all 9 (incl. `lead_conversion` = website pixel lead optimization and `catalog_sales` = Advantage+ catalog ads). TikTok supports the 7 non-`lead_conversion` goals. LinkedIn supports all except app_promotion / lead_conversion. 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, "null"]
          properties:
            amount: { type: number }
            type: { type: string, enum: [daily, lifetime] }
        metrics:
          anyOf:
            - $ref: '#/components/schemas/AdMetrics'
            - type: "null"
        platformAdId: { type: string }
        platformAdAccountId: { type: string }
        platformCampaignId: { type: string }
        platformAdSetId: { type: string }
        campaignName: { type: string }
        adSetName: { type: string }
        platformObjective:
          type: [string, "null"]
          description: "Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC). Only present for Meta ads."
          example: OUTCOME_SALES
        optimizationGoal:
          type: [string, "null"]
          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, "null"]
          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, "null"]
          format: date-time
          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:
          anyOf:
            - $ref: '#/components/schemas/BidStrategy'
            - type: "null"
          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, "null"]
          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, "null"]
          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, "null"]
          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, "null"]
          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, "null"], 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, "null"], 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." }
            objectStoryId: { type: [string, "null"], description: "Meta creative `object_story_id` (the SHARE reference). Frequently absent — Meta omits it for SHARE creatives. Use effectiveObjectStoryId instead." }
            effectiveObjectStoryId: { type: [string, "null"], description: "Meta `effective_object_story_id` — `{pageId}_{postId}` of the Facebook post the ad's engagement (comments) lives on. Pass to GET /v1/ads?effectiveObjectStoryId= to map a Business-Manager-visible post back to this ad; GET /v1/ads/{adId}/comments resolves comments against it." }
            effectiveInstagramMediaId: { type: [string, "null"], description: "Meta `effective_instagram_media_id` — the Instagram media ID of the boosted post the ad's engagement lives on. Pass to GET /v1/ads?effectiveInstagramMediaId= to map a Business-Manager-visible IG post back to this ad." }
            instagramUserId: { type: [string, "null"], description: "Meta `instagram_user_id` — the Instagram-scoped business ID that owns the boosted media." }
            instagramPermalinkUrl: { type: [string, "null"], description: "Meta `instagram_permalink_url` — public Instagram post URL of the boosted media." }
            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
          description: |
            The ad set's targeting (age, gender, geo, interests, placements, audience inclusions/exclusions).
            For ads created through Zernio this is the spec you supplied. For external ads (synced from
            Meta Ads Manager, `isExternal: true`) targeting lives at the ad set and isn't stored at ingest,
            so on the first `GET /v1/ads/{adId}` Zernio resolves it live from Meta and caches it on the ad;
            the value is then Meta's raw `targeting` shape (snake_case, e.g. `geo_locations`, `age_min`),
            the same object Ads Manager shows. May be absent if the ad set exposes no targeting or the lookup fails.
        schedule:
          type: [object, "null"]
          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, "null"]
          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, "null"]
          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, "null"], description: "Meta ad set optimization goal (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION)" }
        bidStrategy:
          anyOf:
            - $ref: '#/components/schemas/BidStrategy'
            - type: "null"
          description: "Bid strategy for this ad set (overrides campaign level when set)"
        bidAmount: { type: [number, "null"], description: "Bid cap in whole currency units. Populated when bidStrategy is LOWEST_COST_WITH_BID_CAP or COST_CAP." }
        roasAverageFloor: { type: [number, "null"], description: "Minimum ROAS as a decimal multiplier (2.0 = 2.0x). Populated when bidStrategy is LOWEST_COST_WITH_MIN_ROAS." }
        promotedObject:
          type: [object, "null"]
          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). When `timeIncrement=1&dailyLevel=ad`, each entry also carries a `daily[]` array of `AdDailyMetrics`.
        daily:
          type: array
          items: { $ref: '#/components/schemas/AdDailyMetrics' }
          description: "Per-day metric series for this ad set. Present only when `GET /v1/ads/tree` is called with `timeIncrement=1` and `dailyLevel` is `adset` or `ad`."
    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, "null"]
          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, "null"]
          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, "null"]
          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, "null"]
          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, "null"]
          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, "null"]
          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, "null"]
          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, "null"], description: "Human-readable advertiser/account name from the platform. Refreshed on every sync." }
        accountId: { type: string }
        profileId: { type: string }
        advertisingChannelType:
          type: [string, "null"]
          description: "Google-only. Raw campaign.advertising_channel_type (SEARCH, PERFORMANCE_MAX, VIDEO, DEMAND_GEN, DISPLAY, SHOPPING, ...). Serving surface, distinct from platformObjective (advertiser intent). Null/absent for non-Google platforms."
        platformObjective: { type: [string, "null"], description: "Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC)" }
        optimizationGoal:
          type: [string, "null"]
          description: "Meta optimization goal shared across ad sets, or comma-separated values when ad sets differ (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION)"
        bidStrategy:
          anyOf:
            - $ref: '#/components/schemas/BidStrategy'
            - type: "null"
          description: "Campaign-level bid strategy. Ad sets inherit this unless they override."
        bidAmount: { type: [number, "null"], 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, "null"], description: "Representative ROAS floor for the campaign — bubbled up from the top-spending ad set. Decimal multiplier (2.0 = 2.0x)." }
        promotedObject:
          type: [object, "null"]
          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' }
        daily:
          type: array
          items: { $ref: '#/components/schemas/AdDailyMetrics' }
          description: "Per-day metric series for this campaign. Present only when `GET /v1/ads/tree` is called with `timeIncrement=1` (any `dailyLevel`). This is the per-campaign daily trend — summing its additive fields reproduces the campaign `metrics` total."
    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, "null"]
          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, "null"]
          description: "Raw platform-level campaign status (Meta `effective_status`)."
        campaignIssuesInfo:
          type: [array, "null"]
          description: "Platform-reported campaign issues (Meta `issues_info[]`)."
          items:
            type: object
        adCount: { type: integer }
        budget:
          type: [object, "null"]
          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, "null"]
          description: Campaign-level budget (CBO). Null for ABO campaigns.
          properties:
            amount: { type: number }
            type: { type: string, enum: [daily, lifetime] }
        budgetLevel:
          type: [string, "null"]
          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, "null"]
          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, "null"], description: "Human-readable advertiser/account name from the platform. Refreshed on every sync." }
        accountId: { type: string }
        profileId: { type: string }
        advertisingChannelType:
          type: [string, "null"]
          description: "Google-only. Raw campaign.advertising_channel_type. See AdTreeCampaign.advertisingChannelType."
        platformObjective: { type: [string, "null"], description: "Raw Meta campaign objective (e.g. OUTCOME_SALES, OUTCOME_LEADS, OUTCOME_TRAFFIC)" }
        optimizationGoal:
          type: [string, "null"]
          description: "Meta optimization goal shared across ad sets, or comma-separated values when ad sets differ (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION)"
        bidStrategy:
          anyOf:
            - $ref: '#/components/schemas/BidStrategy'
            - type: "null"
          description: "Campaign-level bid strategy. Ad sets inherit this unless they override."
        bidAmount: { type: [number, "null"], 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, "null"], description: "Representative ROAS floor from the top-spending ad set. Decimal multiplier (2.0 = 2.0x)." }
        promotedObject:
          type: [object, "null"]
          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 — Meta).

            Per-platform behavior:
            - Meta: free-form; standard names match Meta's built-ins.
            - Google: ignored — the conversion action's category determines the type.
            - LinkedIn: ignored — the conversion rule's `type` is locked to the destination.
          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,
            transactionId on Google, eventId on LinkedIn (LinkedIn deduplicates
            against Insight Tag events with the same eventId; the Insight Tag
            event wins when both arrive).
          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 for Meta and Google. Sent as plaintext to LinkedIn
                (LinkedIn's Conversions API spec requires the raw value).
                Maximum effective list size on LinkedIn is 1.
            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'." }
            city: { type: string, description: "Meta advanced matching (ct). Plaintext city; normalized + SHA-256 hashed server-side. Meta only." }
            state: { type: string, description: "Meta advanced matching (st). 2-letter ANSI for US; hashed server-side. Meta only." }
            zip: { type: string, description: "Meta advanced matching (zp). US uses first 5 digits; hashed server-side. Meta only." }
            dob: { type: string, description: "Meta advanced matching (db). YYYYMMDD; hashed server-side. Meta only." }
            gender: { type: string, description: "Meta advanced matching (ge). 'f' or 'm'; hashed server-side. Meta only." }
            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. }
                li_fat_id:
                  type: string
                  description: |
                    LinkedIn first-party ad tracking click ID. Captured by
                    parsing `li_fat_id` from landing-page URLs after the
                    advertiser enables enhanced conversion tracking on the
                    LinkedIn Insight Tag. Sent to LinkedIn as the
                    LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID userId. Opaque
                    token, not hashed.
        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.

    ConversionDestination:
      type: object
      description: |
        A discoverable conversion destination on an ad platform — a Meta pixel,
        Google conversion action, or LinkedIn conversion rule. Returned by
        `listConversionDestinations`, `getConversionDestination`,
        `createConversionDestination`, and `updateConversionDestination`.
      required: [id, name]
      properties:
        id:
          type: string
          description: |
            Platform-native identifier. Pass back as `destinationId` on event
            send and as the path segment on CRUD endpoints.
        name: { type: string }
        type:
          type: string
          description: |
            Present when the platform locks the event type/category to the
            destination (Google conversion actions, LinkedIn conversion rules).
            Absent for Meta pixels (which accept any event name per request).
        status:
          type: string
          enum: [active, inactive]
          description: |
            For LinkedIn, `inactive` means the rule is soft-deleted (`enabled: false`).
        adAccountId:
          type: string
          description: |
            Set by adapters whose destinations are scoped to a specific ad
            account (LinkedIn). Pass back on subsequent CRUD calls to
            identify the parent ad account.

    CtwaSingleResponse:
      type: object
      description: |
        Response returned by `POST /v1/ads/ctwa` when the request used the
        single-creative shape (top-level headline / body / imageUrl|video).
        `adType` is the union discriminator.
      required: [adType, ad, message]
      properties:
        adType: { type: string, enum: [single] }
        ad: { type: object, description: The persisted Ad document. }
        message: { type: string }

    CtwaMultiResponse:
      type: object
      description: |
        Response returned by `POST /v1/ads/ctwa` when the request used the
        multi-creative shape (`creatives[]`). N persisted Ad documents share
        the returned `platformCampaignId` and `platformAdSetId`. `adType` is
        the union discriminator.
      required: [adType, ads, platformCampaignId, platformAdSetId, message]
      properties:
        adType: { type: string, enum: [multi] }
        ads:
          type: array
          description: |
            The persisted Ad documents (one per creative), all sharing the same
            `platformCampaignId` and `platformAdSetId`.
          items: { type: object }
        platformCampaignId: { type: string }
        platformAdSetId: { type: string }
        message: { type: string }

    TrackingTag:
      type: object
      description: |
        A platform measurement tag — the thing you create, install on a
        website, send events to, and target ads against. On Meta this is a
        Pixel (`kind: pixel`). The shape is platform-neutral so other platforms
        (Pinterest Tag, LinkedIn Insight Tag, etc.) can be added without
        changing the contract; platform-specific fields are simply absent where
        a platform has no equivalent. Returned by `listTrackingTags`,
        `createTrackingTag`, `getTrackingTag`, and `updateTrackingTag`.
      required: [id, name, platform, kind, status]
      properties:
        id:
          type: string
          description: 'Platform-native tag id. Meta: numeric pixel id, as a string.'
        name: { type: string }
        platform: { type: string, enum: [metaads] }
        kind:
          type: string
          enum: [pixel, tag, insight_tag]
          description: 'Platform-native flavor of the tag (Meta: `pixel`).'
        status:
          type: string
          enum: [active, inactive]
          description: '`inactive` when the platform reports the tag as broken/unavailable.'
        code:
          type: string
          description: |
            The base-code `<script>` snippet to install on the site. Meta only;
            populated by `getTrackingTag`, omitted from the list view.
        lastFiredTime:
          type: [integer, "null"]
          description: |
            Unix seconds of the last event the tag received, or `null` if it
            never fired. The practical "is it installed and working" signal.
        isUnavailable:
          type: boolean
          description: Whether the tag is in a broken/unavailable state (Meta `is_unavailable`).
        installed:
          type: boolean
          description: Convenience flag derived from `lastFiredTime` — has the tag ever fired.
        creationTime:
          type: integer
          description: Unix seconds the tag was created.
        ownerBusinessId:
          type: [string, "null"]
          description: |
            Business Manager id that owns the tag, or `null` when the tag lives
            on a personal (non-BM) ad account — such tags can't be shared with
            other ad accounts.
        ownerAdAccountId:
          type: string
          description: 'Ad account id (`act_...`) that owns the tag, when reported.'

    SharedAdAccount:
      type: object
      description: An ad account a tracking tag is shared with (Meta `shared_accounts` edge).
      required: [id]
      properties:
        id: { type: string, description: 'Ad account id, in `act_<digits>` form.' }
        name: { type: string }
        businessId: { type: string, description: "Business Manager id that owns the ad account, when reported." }

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
  post.platform.published:
    post:
      operationId: onPostPlatformPublished
      summary: Post platform published event
      description: |
        Fired once per platform target inside a post as that platform finishes
        publishing successfully. Does NOT wait for the post-level rollup —
        consumers building incremental UIs get notified immediately, even
        when other platforms on the same post are still processing.
        The envelope event (`post.published` / `post.partial`) fires
        separately AFTER all platforms have terminated.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadPostPlatform'
      responses:
        '200':
          description: Webhook received successfully
  post.platform.failed:
    post:
      operationId: onPostPlatformFailed
      summary: Post platform failed event
      description: |
        Fired once per platform target inside a post as that platform fails
        permanently. Temporary/retryable failures do NOT fire this event —
        only permanent ones, so retry loops stay quiet. The envelope event
        (`post.failed` / `post.partial`) fires separately AFTER all
        platforms have terminated.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadPostPlatform'
      responses:
        '200':
          description: Webhook received successfully
  post.tiktok.url_resolved:
    post:
      operationId: onPostTikTokUrlResolved
      summary: TikTok post URL resolved event
      description: |
        Fired when an already-published TikTok platform entry gets its public
        URL backfilled. TikTok exposes the numeric video id asynchronously
        (often minutes after PUBLISH_COMPLETE), so the terminal events can
        carry an empty `publishedUrl` for TikTok. This event delivers
        `platform.publishedUrl` and the resolved `platform.platformPostId`
        once available. At most once per platform target; never fires for
        drafts or private posts (no public URL exists). Payload shape is
        identical to `post.platform.published`.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadPostPlatform'
      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
  conversation.started:
    post:
      operationId: onConversationStarted
      summary: Conversation started event
      description: |
        Fired once when a new conversation begins between one of your connected accounts and a
        contact, in either direction. Works across every DM platform (Instagram, Messenger/Facebook,
        Telegram, WhatsApp, Twitter, Reddit, Bluesky). Naturally deduped — a given conversation
        only fires this event the very first time it appears.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadConversationStarted'
      responses:
        '200':
          description: Webhook received successfully
  call.received:
    post:
      operationId: onCallReceived
      summary: Call received event
      description: |
        Fired when a WhatsApp Business Call connects. For inbound (UIC) calls
        the event fires at the moment our Telnyx trunk bridges the consumer
        leg to the customer&apos;s forward-to destination; for outbound (BIC)
        calls it fires immediately after Meta accepts the connect. Branch on
        `call.direction` to distinguish.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadCallReceived'
      responses:
        '200':
          description: Webhook received successfully
  call.ended:
    post:
      operationId: onCallEnded
      summary: Call ended event
      description: |
        Fired on call hangup with the duration and a zero-markup billing
        breakdown (Meta cost, Telnyx cost, recording surcharge, total).
        Costs are pass-through; no margin is applied.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadCallEnded'
      responses:
        '200':
          description: Webhook received successfully
  call.failed:
    post:
      operationId: onCallFailed
      summary: Call failed event
      description: |
        Fired when a call setup or in-progress call fails (Meta rejected the
        connect, Telnyx returned an error, etc.). Payload carries the
        upstream error code and message.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadCallFailed'
      responses:
        '200':
          description: Webhook received successfully
  call.permission_request:
    post:
      operationId: onCallPermissionRequest
      summary: Call permission request reply event
      description: |
        Fired when a consumer replies to a `call_permission_request`
        interactive message (or its marketing-template variant). Carries
        the response (`accept` / `reject`), whether the grant is permanent,
        and the expiration timestamp when it is temporary.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadCallPermissionRequest'
      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
  reaction.received:
    post:
      operationId: onReactionReceived
      summary: Reaction received event
      description: |
        Fired when a participant adds or removes an emoji reaction on a message.
        Supported on WhatsApp and Telegram. Distinct from message.received so a
        reaction (e.g. a thumbs-up) is not mistaken for an inbound message.
        The `reaction.action` field is `added` or `removed`. On WhatsApp removals
        the platform does not report which emoji was removed, so `reaction.emoji`
        may be an empty string. Requires the Inbox add-on.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadReaction'
      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
  post.external.created:
    post:
      operationId: onPostExternalCreated
      summary: External post created event
      description: |
        Fired when Zernio's background sync detects a natively-authored post
        (created outside Zernio, e.g. a Google Business Profile localPost made in
        the Google UI) for the first time. Poll-driven (~hourly), not real-time.
        `post.source` is always "external".
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadExternalPost'
      responses:
        '200':
          description: Webhook received successfully
  post.external.updated:
    post:
      operationId: onPostExternalUpdated
      summary: External post updated event
      description: |
        Fired when a tracked native post's text or media changed on the platform.
        Detected by comparing text/media structure and, where available, the
        platform's own edit timestamp; a media-URL-only refresh does not fire this.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadExternalPost'
      responses:
        '200':
          description: Webhook received successfully
  post.external.deleted:
    post:
      operationId: onPostExternalDeleted
      summary: External post deleted event
      description: |
        Fired when a tracked native post is detected as removed from the platform.
        `post.deletedAt` carries the detection time. Coverage is bounded to the
        most recent posts the platform listing returns.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadExternalPost'
      responses:
        '200':
          description: Webhook received successfully
  lead.received:
    post:
      operationId: onLeadReceived
      summary: Lead received event
      description: |
        Fired when a new lead is submitted against a Meta Lead Gen (Instant) Form
        and ingested via the Page `leadgen` webhook. `lead.fields` is the
        question-key to answer map; `lead.formId` / `lead.adId` give provenance.
        Requires the Ads add-on.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadLead'
      responses:
        '200':
          description: Webhook received successfully
  ad.status_changed:
    post:
      operationId: onAdStatusChanged
      summary: Ad status changed event
      description: |
        Fired when a campaign, ad set, or ad on a connected ad platform changes status.
        Currently emitted only for Meta (`metaads`).

        Subscribed to two Meta `ad_account` webhook fields:
          - `in_process_ad_objects` - the ad object finished processing and exited
            the `IN_PROCESS` state. `status.raw` carries Meta's `status_name`
            (e.g. `ACTIVE`, `PAUSED`, `ARCHIVED`, `DELETED`).
          - `with_issues_ad_objects` - the ad object entered the `WITH_ISSUES`
            state. `status.raw` is set to `WITH_ISSUES` and the `error` block is
            populated from Meta's `error_code` / `error_summary` / `error_message`.

        `adObject.level` mirrors Meta's `level` and is one of `CAMPAIGN`,
        `AD_SET`, or `AD`. Creative-level events are not forwarded.

        Branch on `status.raw` to handle each transition; use `error.code` (when
        present) as the stable discriminator — `error.summary` and `error.message`
        are localized to the ad-account owner's Meta locale.

        The `error` block is optional. It's present on most `WITH_ISSUES`
        events but can be absent (Meta does not always include diagnostics),
        and is never present on any other status. Always null-check `error`
        before reading `error.code`.

        **Fan-out:** matching is keyed on `adObject.platformAdAccountId`. When
        multiple connected Zernio `metaads` accounts are linked to the same Meta
        ad account, each receives its own delivery.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadAdStatusChanged'
            examples:
              inProcessActive:
                summary: in_process_ad_objects → ACTIVE (ad approved)
                value:
                  id: "01J7K3P4N5Q6R7S8T9V0W1X2Y3"
                  event: ad.status_changed
                  account:
                    accountId: "65c8f7e2a1b3c4d5e6f7a8b9"
                    profileId: "65c8f7e2a1b3c4d5e6f7a800"
                    platform: metaads
                    username: acme-ads
                    displayName: Acme Ads
                  adObject:
                    level: AD
                    platformId: "120244894077860689"
                    platformAdAccountId: act_2129800524463520
                  status:
                    raw: ACTIVE
                  timestamp: "2026-05-05T15:25:27.944Z"
              withIssuesError:
                summary: with_issues_ad_objects → WITH_ISSUES (error populated)
                value:
                  id: "01J7K3P4N5Q6R7S8T9V0W1X2Y4"
                  event: ad.status_changed
                  account:
                    accountId: "65c8f7e2a1b3c4d5e6f7a8b9"
                    profileId: "65c8f7e2a1b3c4d5e6f7a800"
                    platform: metaads
                    username: acme-ads
                    displayName: Acme Ads
                  adObject:
                    level: AD
                    platformId: "120244560555500043"
                    platformAdAccountId: act_587875401826220
                  status:
                    raw: WITH_ISSUES
                  error:
                    code: "2643001"
                    summary: Ad Processing Error
                    message: "Ad Processing Error: We are having trouble processing your request. To resolve the issue, please try to publish again."
                  timestamp: "2026-05-08T04:22:35.821Z"
              inProcessWithIssues:
                summary: in_process_ad_objects → WITH_ISSUES (no error block)
                value:
                  id: "01J7K3P4N5Q6R7S8T9V0W1X2Y5"
                  event: ad.status_changed
                  account:
                    accountId: "65c8f7e2a1b3c4d5e6f7a8b9"
                    profileId: "65c8f7e2a1b3c4d5e6f7a800"
                    platform: metaads
                    username: acme-ads
                  adObject:
                    level: AD
                    platformId: "120244560555500043"
                    platformAdAccountId: act_587875401826220
                  status:
                    raw: WITH_ISSUES
                  timestamp: "2026-05-08T04:22:35.821Z"
      responses:
        '200':
          description: Webhook received successfully
  whatsapp.template.status_updated:
    post:
      operationId: onWhatsAppTemplateStatusUpdated
      summary: WhatsApp template status updated event
      description: |
        Fired when Meta finishes (re)reviewing a WhatsApp Business template
        attached to a connected WABA. Forwarded from Meta's
        `message_template_status_update` webhook field on the WhatsApp
        Business Account. Consumers branch on `template.status` (APPROVED,
        REJECTED, PENDING, PAUSED, DISABLED, IN_APPEAL, PENDING_DELETION).
        Meta does not include the previous status or the template's category
        in this event.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              $ref: '#/components/schemas/WebhookPayloadWhatsAppTemplateStatusUpdated'
      responses:
        '200':
          description: Webhook received successfully
  whatsapp.number.activated:
    post:
      operationId: onWhatsAppNumberActivated
      summary: WhatsApp number activated event
      description: |
        Fired when a purchased WhatsApp number becomes active and usable — both
        the synchronous (Tier 1/2) path and the asynchronous regulated (Tier
        3/4) path land here. Lets integrators react without polling
        GET /v1/phone-numbers.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                id: { type: string }
                event: { type: string, enum: [whatsapp.number.activated] }
                timestamp: { type: string, format: date-time }
                number:
                  type: object
                  properties:
                    id: { type: string }
                    phoneNumber: { type: string }
                    country: { type: string }
                    profileId: { type: string }
      responses:
        '200':
          description: Webhook received successfully
  whatsapp.number.declined:
    post:
      operationId: onWhatsAppNumberDeclined
      summary: WhatsApp number declined event
      description: |
        Fired when a regulated (Tier 3/4) number order is declined or fails
        review. The number is never billed. `reason` carries the reviewer's
        rejection reason when available.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                id: { type: string }
                event: { type: string, enum: [whatsapp.number.declined] }
                timestamp: { type: string, format: date-time }
                number:
                  type: object
                  properties:
                    id: { type: string }
                    phoneNumber: { type: string }
                    country: { type: string }
                    profileId: { type: string }
                reason: { type: [string, "null"] }
      responses:
        '200':
          description: Webhook received successfully
  whatsapp.number.action_required:
    post:
      operationId: onWhatsAppNumberActionRequired
      summary: WhatsApp number action required event
      description: |
        Fired when the regulator asks for more information on an already-placed
        regulated number order. The number stays pending (nothing was rejected);
        the customer can provide the missing information from the dashboard, or
        via the remediation endpoint. `reason` carries the regulator's request
        verbatim when available.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                id: { type: string }
                event: { type: string, enum: [whatsapp.number.action_required] }
                timestamp: { type: string, format: date-time }
                reason: { type: string }
                number:
                  type: object
                  properties:
                    id: { type: string }
                    phoneNumber: { type: string }
                    country: { type: string }
                    profileId: { type: string }
      responses:
        '200':
          description: Webhook received successfully
  whatsapp.number.verification_required:
    post:
      operationId: onWhatsAppNumberVerificationRequired
      summary: WhatsApp number verification-required event
      description: |
        Fired when a regulated number has an out-of-band identity-verification
        step (e.g. Onfido). `verificationUrl` is the link to forward to the
        number's end user; the order completes once they pass.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                id: { type: string }
                event: { type: string, enum: [whatsapp.number.verification_required] }
                timestamp: { type: string, format: date-time }
                number:
                  type: object
                  properties:
                    id: { type: string }
                    phoneNumber: { type: string }
                    country: { type: string }
                    profileId: { type: string }
                verificationUrl: { type: string }
      responses:
        '200':
          description: Webhook received successfully
  whatsapp.number.suspended:
    post:
      operationId: onWhatsAppNumberSuspended
      summary: WhatsApp number suspended event
      description: |
        Fired when an active number is suspended (e.g. a failed payment). The
        number stops working until the issue is resolved, after which a
        `whatsapp.number.reactivated` event is sent. `reason` carries the cause
        (e.g. `payment_failed`, `subscription_ended`).
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                id: { type: string }
                event: { type: string, enum: [whatsapp.number.suspended] }
                timestamp: { type: string, format: date-time }
                number:
                  type: object
                  properties:
                    id: { type: string }
                    phoneNumber: { type: string }
                    country: { type: string }
                    profileId: { type: string }
                reason: { type: [string, "null"] }
      responses:
        '200':
          description: Webhook received successfully
  whatsapp.number.reactivated:
    post:
      operationId: onWhatsAppNumberReactivated
      summary: WhatsApp number reactivated event
      description: |
        Fired when a suspended number is reactivated (e.g. the payment
        recovered) and is usable again.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                id: { type: string }
                event: { type: string, enum: [whatsapp.number.reactivated] }
                timestamp: { type: string, format: date-time }
                number:
                  type: object
                  properties:
                    id: { type: string }
                    phoneNumber: { type: string }
                    country: { type: string }
                    profileId: { type: string }
      responses:
        '200':
          description: Webhook received successfully
  whatsapp.number.released:
    post:
      operationId: onWhatsAppNumberReleased
      summary: WhatsApp number released event
      description: |
        Fired when a number is released and is no longer usable (by the user, a
        billing cleanup, or an admin). Terminal. `reason` carries the cause
        (e.g. `user_requested`, `cleanup_suspended`).
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                id: { type: string }
                event: { type: string, enum: [whatsapp.number.released] }
                timestamp: { type: string, format: date-time }
                number:
                  type: object
                  properties:
                    id: { type: string }
                    phoneNumber: { type: string }
                    country: { type: string }
                    profileId: { type: string }
                reason: { type: [string, "null"] }
      responses:
        '200':
          description: Webhook received successfully
  whatsapp.number.kyc_submitted:
    post:
      operationId: onWhatsAppNumberKycSubmitted
      summary: WhatsApp number KYC submitted event
      description: |
        Fired when an end customer completes a hosted KYC share link
        (POST /v1/phone-numbers/kyc/share). The number enters review
        (pending_regulatory) under your account; `whatsapp.number.activated` or
        `whatsapp.number.declined` follows once the provider rules on it.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                id: { type: string }
                event: { type: string, enum: [whatsapp.number.kyc_submitted] }
                timestamp: { type: string, format: date-time }
                number:
                  type: object
                  properties:
                    id: { type: string }
                    phoneNumber: { type: string }
                    country: { type: string }
                    profileId: { type: string }
      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 }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /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: { $ref: '#/components/schemas/MediaItem' }
                  example:
                    - platform: youtube
                    - platform: twitter
                mediaItems:
                  type: array
                  description: Root media items shared across platforms
                  items: { $ref: '#/components/schemas/MediaItem' }
      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 }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /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, "null"], 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 }
        "401": { $ref: "#/components/responses/Unauthorized" }

  /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 }
        "401": { $ref: "#/components/responses/Unauthorized" }

  # ============================================
  # 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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
          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 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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
        '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 org 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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
        '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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
        '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"
                    durationSeconds: 213
                    dateRange:
                      startDate: "2025-01-01"
                      endDate: "2025-01-12"
                    totalViews: 12345
                    dailyViews:
                      - date: "2025-01-12"
                        views: 1234
                        estimatedMinutesWatched: 567.5
                        averageViewDuration: 45.2
                        averageViewPercentage: 21.2
                        subscribersGained: 10
                        subscribersLost: 2
                        likes: 89
                        comments: 12
                        shares: 5
                      - date: "2025-01-11"
                        views: 987
                        estimatedMinutesWatched: 432.1
                        averageViewDuration: 43.8
                        averageViewPercentage: 20.6
                        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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
          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/youtube/video-retention:
    get:
      operationId: getYouTubeVideoRetention
      tags: [Analytics]
      summary: Get YouTube video retention curve
      description: |
        Returns the audience retention curve for a single YouTube video, plus the video's
        duration for rendering the curve on a time axis. The curve has up to 100 points
        (elapsedVideoTimeRatio 0.01-1.0) aggregated over the whole date range; YouTube does
        not support per-day retention breakdowns.

        audienceWatchRatio is the absolute share of viewers watching at that point in the
        video and can exceed 1 (rewinds and looping, common on Shorts). relativeRetentionPerformance
        compares against videos of similar length (0 = worst, 0.5 = median, 1 = best).
        YouTube returns an empty curve for videos with very few views or before analytics
        processing completes (2-3 day delay).

        Requires yt-analytics.readonly scope (re-authorization may be needed).
      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 the video's publish date (lifetime curve).
        - 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: Audience retention curve
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/YouTubeVideoRetentionResponse'
              examples:
                success:
                  summary: Successful response with retention curve
                  value:
                    success: true
                    accountId: "65f1a2b3c4d5e6f7a8b9c0d1"
                    videoId: "zF7Gp4jnDa0"
                    title: "what's your choice?"
                    publishedAt: "2026-05-20T12:00:00Z"
                    durationSeconds: 24
                    dateRange:
                      startDate: "2026-05-20"
                      endDate: "2026-06-09"
                    retentionCurve:
                      - elapsedVideoTimeRatio: 0.01
                        audienceWatchRatio: 1.6956
                        relativeRetentionPerformance: 0.73665
                        startedWatching: 280331
                        stoppedWatching: 5348
                        totalSegmentImpressions: 478466
                      - elapsedVideoTimeRatio: 0.02
                        audienceWatchRatio: 1.678
                        relativeRetentionPerformance: 0.73865
                        startedWatching: 385
                        stoppedWatching: 11790
                        totalSegmentImpressions: 473492
                    scopeStatus:
                      hasAnalyticsScope: true
        '400':
          description: Bad request (missing or invalid parameters)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
          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: Video not found, or it does not belong to this YouTube channel
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Video not found on this YouTube channel" }
                  type: { type: string, example: "not_found" }
                  code: { type: string, example: "video_not_found" }
                  param: { type: string, example: "videoId" }
        '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 video retention 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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
        '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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
          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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
        '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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
          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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
          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.
        - name: attribution
          in: query
          schema:
            type: string
            enum: [publish, received]
            default: publish
          description: |
            How each post's engagement is attributed to a day.
            "publish" (default) sums each post's lifetime total on its publish date.
            "received" buckets the per-day increase in engagement by the day it actually arrived (engagement-over-time), so engagement on older posts appears on the day it was gained rather than the post's publish date.
      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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
          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: accountId
          in: query
          schema: { type: string }
          description: Filter by social account ID. Omit for all accounts.
        - 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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
          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: accountId
          in: query
          schema: { type: string }
          description: Filter by social account ID. Omit for all accounts.
        - 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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
          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: accountId
          in: query
          schema: { type: string }
          description: Filter by social account ID. Omit for all accounts.
        - 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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
          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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
          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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
          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: "zernio"
                        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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
          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" }

  # ─── Inbox analytics ───────────────────────────────────────────────────
  # Same `[Analytics]` tag as the posting endpoints above so the rendered
  # docs keep posting + inbox analytics centralized in one group. Every
  # endpoint here requires `fromDate` (YYYY-MM-DD) and caps the date span
  # at 365 days.
  /v1/analytics/inbox/volume:
    get:
      operationId: getInboxVolume
      tags: [Inbox Analytics]
      summary: Get inbox messaging volume
      description: |
        Daily inbox messaging volume + breakdowns. Folds the raw messaging
        events into three projections so the client can render the volume
        chart, KPI strip, and per-platform stacked bar from a single call.
        Max date range is 365 days.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: fromDate
          in: query
          required: true
          schema: { type: string, format: date }
          description: Inclusive lower bound (YYYY-MM-DD). Required.
        - name: toDate
          in: query
          schema: { type: string, format: date }
          description: Inclusive upper bound (YYYY-MM-DD). Defaults to today.
        - name: profileId
          in: query
          schema: { type: string }
        - name: platform
          in: query
          schema: { type: string }
          description: Filter by single platform (facebook, instagram, twitter, etc.).
        - name: accountId
          in: query
          schema: { type: string }
        - name: source
          in: query
          schema: { type: string }
          description: "Filter by metadata.source lineage (human, workflow, sequence, broadcast, comment_automation, api, contact, platform)."
      responses:
        '200':
          description: Volume breakdown
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  from: { type: string, format: date }
                  to: { type: [string, "null"], format: date }
                  summary:
                    type: object
                    properties:
                      received: { type: integer }
                      sent: { type: integer }
                      read: { type: integer }
                      failed: { type: integer }
                      uniqueConversations: { type: integer }
                  timeseries:
                    type: array
                    items:
                      type: object
                      properties:
                        date: { type: string, format: date }
                        sent: { type: integer }
                        received: { type: integer }
                        read: { type: integer }
                        failed: { type: integer }
                  byPlatform:
                    type: array
                    items:
                      type: object
                      properties:
                        platform: { type: string }
                        sent: { type: integer }
                        received: { type: integer }
                        read: { type: integer }
                        failed: { type: integer }
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  details: { type: object }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v1/analytics/inbox/heatmap:
    get:
      operationId: getInboxHeatmap
      tags: [Inbox Analytics]
      summary: Get day × hour heatmap
      description: |
        Day-of-week × hour-of-day breakdown of inbox messages. Buckets are
        sparse — only cells with at least one event are returned; clients
        zero-fill the rest to render the full 7×24 grid. The `dow` field
        follows ClickHouse's `toDayOfWeek` convention (1 = Monday … 7 =
        Sunday). Max date range is 365 days.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: fromDate
          in: query
          required: true
          schema: { type: string, format: date }
        - name: toDate
          in: query
          schema: { type: string, format: date }
        - name: profileId
          in: query
          schema: { type: string }
        - name: platform
          in: query
          schema: { type: string }
        - name: accountId
          in: query
          schema: { type: string }
        - name: source
          in: query
          schema: { type: string }
        - name: action
          in: query
          schema: { type: string, enum: [message.received, message.sent, message.read, all] }
          description: Narrow to a single event type. "all" or omitted means no filter.
      responses:
        '200':
          description: Heatmap buckets
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  from: { type: string, format: date }
                  to: { type: [string, "null"], format: date }
                  buckets:
                    type: array
                    items:
                      type: object
                      properties:
                        dow: { type: integer, minimum: 1, maximum: 7, description: "1 = Monday, 7 = Sunday" }
                        hour: { type: integer, minimum: 0, maximum: 23 }
                        received: { type: integer }
                        sent: { type: integer }
                        read: { type: integer }
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  details: { type: object }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v1/analytics/inbox/source-breakdown:
    get:
      operationId: getInboxSourceBreakdown
      tags: [Inbox Analytics]
      summary: Get inbox source breakdown
      description: |
        Breakdown of inbox messages by their lineage source (the
        `metadata.source` field set at ingest time: human / workflow /
        sequence / broadcast / comment_automation / api / contact /
        platform). Each source row also carries a per-platform sub-split.
        Max date range is 365 days.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: fromDate
          in: query
          required: true
          schema: { type: string, format: date }
        - name: toDate
          in: query
          schema: { type: string, format: date }
        - name: profileId
          in: query
          schema: { type: string }
        - name: platform
          in: query
          schema: { type: string }
        - name: accountId
          in: query
          schema: { type: string }
      responses:
        '200':
          description: Source breakdown
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  from: { type: string, format: date }
                  to: { type: [string, "null"], format: date }
                  sources:
                    type: array
                    items:
                      type: object
                      properties:
                        source: { type: string }
                        received: { type: integer }
                        sent: { type: integer }
                        read: { type: integer }
                        byPlatform:
                          type: array
                          items:
                            type: object
                            properties:
                              platform: { type: string }
                              received: { type: integer }
                              sent: { type: integer }
                              read: { type: integer }
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  details: { type: object }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v1/analytics/inbox/response-time:
    get:
      operationId: getInboxResponseTime
      tags: [Inbox Analytics]
      summary: Get inbox response-time stats
      description: |
        Time-to-first-response stats. Pairs each received message with the
        next sent message in the same conversation and reports the delta
        as both summary statistics and a fixed-bucket histogram suited
        for the analytics page's TTR chart.

        `sampleSize` reflects only conversations that received AND got a
        reply in the window — received-but-never-answered conversations
        are excluded. Compare against /v1/analytics/inbox/volume's
        `summary.received` to compute reply rate.

        Max date range is 365 days.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: fromDate
          in: query
          required: true
          schema: { type: string, format: date }
        - name: toDate
          in: query
          schema: { type: string, format: date }
        - name: profileId
          in: query
          schema: { type: string }
        - name: platform
          in: query
          schema: { type: string }
        - name: accountId
          in: query
          schema: { type: string }
      responses:
        '200':
          description: Response-time summary + histogram
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  from: { type: string, format: date }
                  to: { type: [string, "null"], format: date }
                  summary:
                    type: object
                    properties:
                      sampleSize: { type: integer }
                      medianSeconds: { type: integer }
                      p90Seconds: { type: integer }
                      p99Seconds: { type: integer }
                      meanSeconds: { type: integer }
                      fastestSeconds: { type: integer }
                      slowestSeconds: { type: integer }
                  histogram:
                    type: array
                    items:
                      type: object
                      properties:
                        bucket: { type: string, description: "Human label (0-1m, 1-5m, 5-15m, 15-60m, 1-4h, 4-24h, 1d+)" }
                        lowerSeconds: { type: integer }
                        upperSeconds: { type: [integer, "null"], description: "null on the open-ended last bucket" }
                        count: { type: integer }
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  details: { type: object }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v1/analytics/inbox/top-accounts:
    get:
      operationId: getInboxTopAccounts
      tags: [Inbox Analytics]
      summary: Get top accounts by inbox volume
      description: |
        Leaderboard of social accounts by inbox message volume. Decorates
        each row with display labels from the live SocialAccount record
        (so the UI shows username + displayName, not just an ID). Accounts
        that no longer map to a SocialAccount surface as "(disconnected)"
        so the row stays visible. Max date range is 365 days.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: fromDate
          in: query
          required: true
          schema: { type: string, format: date }
        - name: toDate
          in: query
          schema: { type: string, format: date }
        - name: profileId
          in: query
          schema: { type: string }
        - name: platform
          in: query
          schema: { type: string }
        - name: source
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
          description: "Cap on returned rows. Lower than the posting listing's 100 because each row triggers a SocialAccount Mongo lookup."
      responses:
        '200':
          description: Top accounts leaderboard
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  from: { type: string, format: date }
                  to: { type: [string, "null"], format: date }
                  accounts:
                    type: array
                    items:
                      type: object
                      properties:
                        accountId: { type: string }
                        platform: { type: string }
                        displayName: { type: string, description: "(disconnected) when the SocialAccount no longer exists" }
                        username: { type: string }
                        received: { type: integer }
                        sent: { type: integer }
                        total: { type: integer }
                        conversations: { type: integer }
                        medianResponseSeconds: { type: integer }
                        repliedCount: { type: integer, description: "Distinguishes 'instant replies' from 'no replies at all' so a zero medianResponseSeconds with repliedCount=0 renders as '—' instead of '0s'" }
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  details: { type: object }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v1/analytics/inbox/conversations:
    get:
      operationId: listInboxConversationAnalytics
      tags: [Inbox Analytics]
      summary: List conversation analytics
      description: |
        Per-conversation listing with per-row totals + first/last message
        timestamps. The inbox analog of GET /v1/analytics (posts listing) —
        same filter shape, same pagination, same sort/order semantics.
        Use as the entry point for the per-conversation analytics drawer
        at /v1/analytics/inbox/conversations/{conversationId}.

        Rows are enriched with the conversation's participant info
        (`participantName`, `participantUsername`, `participantPicture`)
        and last-message preview by joining the Conversation document
        scoped to the caller's team. Max date range is 365 days.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: fromDate
          in: query
          required: true
          schema: { type: string, format: date }
        - name: toDate
          in: query
          schema: { type: string, format: date }
        - name: profileId
          in: query
          schema: { type: string }
        - name: platform
          in: query
          schema: { type: string }
        - name: accountId
          in: query
          schema: { type: string }
        - name: source
          in: query
          schema: { type: string }
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
        - name: page
          in: query
          schema: { type: integer, minimum: 1, default: 1 }
        - name: sortBy
          in: query
          schema: { type: string, enum: [lastMessageAt, firstMessageAt, totalMessages, received, sent, read, failed], default: lastMessageAt }
        - name: order
          in: query
          schema: { type: string, enum: [asc, desc], default: desc }
      responses:
        '200':
          description: Paginated conversation analytics list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  from: { type: string, format: date }
                  to: { type: [string, "null"], format: date }
                  items:
                    type: array
                    items:
                      type: object
                      properties:
                        conversationId: { type: string, description: "The platformConversationId (the same identity used by metadata.conversationId)" }
                        mongoId: { type: [string, "null"], description: "The Conversation document _id, when a matching doc exists" }
                        accountId: { type: string }
                        platform: { type: string }
                        participantName: { type: [string, "null"] }
                        participantUsername: { type: [string, "null"] }
                        participantPicture: { type: [string, "null"] }
                        lastMessage: { type: [string, "null"], description: "Cached preview from the Conversation doc" }
                        totalMessages: { type: integer }
                        received: { type: integer }
                        sent: { type: integer }
                        read: { type: integer }
                        failed: { type: integer }
                        firstMessageAt: { type: string, format: date-time }
                        lastMessageAt: { type: string, format: date-time }
                  pagination:
                    type: object
                    properties:
                      page: { type: integer }
                      limit: { type: integer }
                      total: { type: integer }
                      totalPages: { type: integer }
                      hasMore: { type: boolean }
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  details: { type: object }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v1/analytics/inbox/conversations/{conversationId}:
    get:
      operationId: getInboxConversationAnalytics
      tags: [Inbox Analytics]
      summary: Get conversation analytics
      description: |
        Per-conversation inbox analytics. The inbox analog of
        /v1/analytics/post-timeline — one conversation, daily totals,
        source mix.

        The {conversationId} path param accepts EITHER the Mongo `_id` of
        the Conversation document OR its `platformConversationId` (the
        same identity used by metadata.conversationId at ingest time).
        Ownership is verified in MongoDB against the caller's team
        before the Tinybird query fires.

        Max date range is 365 days.
      security: [{ bearerAuth: [] }]
      parameters:
        - name: conversationId
          in: path
          required: true
          schema: { type: string }
          description: Mongo _id or platformConversationId.
        - name: fromDate
          in: query
          required: true
          schema: { type: string, format: date }
        - name: toDate
          in: query
          schema: { type: string, format: date }
      responses:
        '200':
          description: Per-conversation analytics
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  conversationId: { type: string, description: "The platformConversationId" }
                  mongoId: { type: string }
                  platform: { type: [string, "null"] }
                  from: { type: string, format: date }
                  to: { type: [string, "null"], format: date }
                  summary:
                    type: object
                    properties:
                      received: { type: integer }
                      sent: { type: integer }
                      read: { type: integer }
                      failed: { type: integer }
                      totalMessages: { type: integer }
                      firstMessageAt: { type: [string, "null"], format: date-time }
                      lastMessageAt: { type: [string, "null"], format: date-time }
                  timeseries:
                    type: array
                    items:
                      type: object
                      properties:
                        date: { type: string, format: date }
                        sent: { type: integer }
                        received: { type: integer }
                        read: { type: integer }
                        failed: { type: integer }
                  bySource:
                    type: array
                    items:
                      type: object
                      properties:
                        source: { type: string, description: "(unspecified) for legacy rows with no metadata.source" }
                        count: { type: integer }
        '400':
          description: Validation error
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  details: { type: object }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: Conversation not found or not owned by the caller's team
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "Conversation not found." }
                  code: { type: string, example: conversation_not_found }
        '500':
          description: Internal server error
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'

  /v1/account-groups:
    get:
      operationId: listAccountGroups
      tags: [Account Groups]
      summary: List groups
      description: |
        Returns all account groups visible to the authenticated user. Groups can
        contain accounts from multiple profiles. For API keys scoped to specific
        profiles, only groups whose accounts all live in allowed profiles are
        returned.
      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 }
                        createdBy: { type: string }
                        profileId:
                          type: string
                          description: |
                            Legacy field. Present only on groups created before
                            cross-profile groups were supported. New groups omit it.
              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.
        Accounts can belong to different profiles; the caller must have access to
        every account's profile. Group names must be unique per user.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name, accountIds]
              properties:
                name: { type: string }
                accountIds:
                  type: array
                  items: { type: string }
                profileId:
                  type: string
                  description: |
                    Deprecated. Accepted for backward compatibility but ignored.
                    Groups are no longer scoped to a single profile.
                  deprecated: true
            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
                  expiresIn:
                    type: integer
                    description: Seconds until the presigned uploadUrl expires (always 3600)
              example:
                uploadUrl: "<presigned-upload-url>"
                publicUrl: "https://media.zernio.com/temp/1234567890_abc123_my-video.mp4"
                key: "temp/1234567890_abc123_my-video.mp4"
                expiresIn: 3600
        '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: "Missing required fields: filename and contentType" }
                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, "null"] }
                  before: { type: [string, "null"] }
              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, "null"] }
                  before: { type: [string, "null"] }
              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/billing/x-pricing:
    get:
      operationId: getXApiPricing
      tags: [Usage]
      summary: Get X/Twitter API pricing table
      description: |
        Returns Zernio's canonical X/Twitter API pricing table. Each X action has its
        own Metronome product and its own rate, and Zernio passes X API costs through
        at exact rates with zero markup.

        The response is identical for every authenticated user (pricing is universal),
        so it is safe to cache on the client for the duration of a billing period.

        To compute your own per-operation spend, pair this endpoint with
        `GET /v1/usage-stats` — that endpoint returns `usage.xApiCallsByOperation`
        keyed by the same `operation` field you get here.
      responses:
        '200':
          description: X pricing table
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/XApiPricing'
              example:
                currency: USD
                markup: "0%"
                source: "https://developer.x.com/#pricing"
                lastVerified: "2026-03-26"
                tiers:
                  - { tier: x_api_005, pricePerCallUsd: 0.005, operationCount: 13 }
                  - { tier: x_api_010, pricePerCallUsd: 0.010, operationCount: 8 }
                  - { tier: x_api_015, pricePerCallUsd: 0.015, operationCount: 3 }
                  - { tier: x_api_200, pricePerCallUsd: 0.200, operationCount: 1 }
                operations:
                  - operation: posts_read
                    eventType: x_posts_read
                    displayName: "X API: Posts Read"
                    pricePerCallUsd: 0.005
                    pricePerCallCents: 0.5
                    tier: x_api_005
                    triggeredBy:
                      - { method: getPostAnalytics, metering: analytics_optin }
                      - { method: getBatchPostAnalytics, metering: analytics_optin }
                      - { method: getAccountPosts, metering: analytics_optin }
                  - operation: content_create
                    eventType: x_content_create
                    displayName: "X API: Content Create"
                    pricePerCallUsd: 0.015
                    pricePerCallCents: 1.5
                    tier: x_api_015
                    triggeredBy:
                      - { method: publishPost, metering: always }
                  - operation: content_create_with_url
                    eventType: x_content_create_with_url
                    displayName: "X API: Content Create (with URL)"
                    pricePerCallUsd: 0.200
                    pricePerCallCents: 20.0
                    tier: x_api_200
                    triggeredBy:
                      - { method: publishPost, metering: always }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/usage:
    get:
      operationId: getUsage
      tags: [Usage]
      summary: Get plan and usage snapshot
      description: |
        The usage hub: current plan name, billing period, plan limits, and
        usage counts, in one snapshot. For metered consumption over an
        arbitrary window with breakdowns (by day, by number), use the
        domain spokes: `GET /v1/usage/calls` and `GET /v1/usage/sms`.

        The response shape depends on the account's `billingSystem`:
          * Stripe users: per-period `usage.uploads` / `usage.profiles` counters.
          * Metronome (usage-based) users: `usage.connectedAccounts`,
            `usage.xApiCallsByOperation` (per-operation X API call counts —
            resolve keys via `GET /v1/billing/x-pricing`), plus a `spend`
            block with `currentPeriodCents`, `xSpendCents`, and
            `xSpendLimitCents`. The legacy `usage.xApiCalls` 3-tier
            aggregate is still emitted for back-compat but excludes the
            $0.200 URL tier and any future tiers — new clients should
            consume `xApiCallsByOperation` only.
      parameters:
        - name: reconcile
          in: query
          required: false
          schema:
            type: boolean
          description: |
            For Stripe subscription users, `true` forces a subscription
            reconciliation pass even when cached plan data looks complete.
            Omit the parameter, or pass `false`, to use the default
            first-time-only reconciliation behavior. Invalid boolean values are
            rejected.
      responses:
        '200':
          description: Usage snapshot
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageStats'
        '400': { description: Invalid query parameter }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/usage-stats:
    get:
      operationId: getUsageStats
      tags: [Usage]
      summary: Get plan and usage stats
      deprecated: true
      description: |
        Deprecated alias of `GET /v1/usage`; same contract. New integrations
        should use that path (the usage hub), with `GET /v1/usage/calls` and
        `GET /v1/usage/sms` for metered breakdowns.

        Returns the current plan name, billing period, plan limits, and usage counts.

        The response shape depends on the account's `billingSystem`:
          * Stripe users: per-period `usage.uploads` / `usage.profiles` counters.
          * Metronome (usage-based) users: `usage.connectedAccounts`,
            `usage.xApiCallsByOperation` (per-operation X API call counts —
            resolve keys via `GET /v1/billing/x-pricing`), plus a `spend`
            block with `currentPeriodCents`, `xSpendCents`, and
            `xSpendLimitCents`. The legacy `usage.xApiCalls` 3-tier
            aggregate is still emitted for back-compat but excludes the
            $0.200 URL tier and any future tiers — new clients should
            consume `xApiCallsByOperation` only.
      parameters:
        - name: reconcile
          in: query
          required: false
          schema:
            type: boolean
          description: |
            For Stripe subscription users, `true` forces a subscription
            reconciliation pass even when cached plan data looks complete.
            Omit the parameter, or pass `false`, to use the default
            first-time-only reconciliation behavior. Invalid boolean values are
            rejected.
      responses:
        '200':
          description: Usage stats
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/UsageStats'
              examples:
                stripe:
                  summary: Stripe subscription user
                  value:
                    billingSystem: stripe
                    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"
                    hasAccess: true
                    isInvitedUser: false
                    autoUpgradeEnabled: false
                metronome:
                  summary: Metronome (usage-based) user
                  value:
                    billingSystem: metronome
                    planName: "Usage-Based"
                    billingPeriod: "monthly"
                    limits:
                      uploads: -1
                      profiles: -1
                    usage:
                      connectedAccounts: 5
                      # DEPRECATED — kept for back-compat. Excludes the
                      # $0.200 URL tier; use xApiCallsByOperation.
                      xApiCalls:
                        x_api_005: 42
                        x_api_010: 1
                        x_api_015: 7
                      xApiCallsByOperation:
                        posts_read: 42
                        content_create: 7
                        content_create_with_url: 3
                        dm_event_read: 1
                        dm_interaction_create: 1
                    spend:
                      currentPeriodCents: 1293
                      creditsRemainingCents: 0
                      # 42 × $0.005 + 7 × $0.015 + 3 × $0.200 + 1 × $0.010 + 1 × $0.015
                      # = $0.21 + $0.105 + $0.60 + $0.01 + $0.015 = $0.94 -> 94¢
                      xSpendCents: 94
                      xSpendLimitCents: 1000
                    hasAccess: true
                    isInvitedUser: false
                    autoUpgradeEnabled: false
        '400': { description: Invalid query parameter }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/usage/calls:
    get:
      operationId: getCallsUsage
      tags: [Usage]
      summary: Calling usage (volumes + billable cost)
      description: |
        Aggregated calling usage across your numbers, both channels
        (WhatsApp Business Calling + regular phone/PSTN): call counts,
        answered counts, minutes, and cost. Use it for cost visibility or to
        rebill your own customers per number.

        Costs come from each call's billing snapshot, so this endpoint always
        agrees with the invoice: `billableUSD` is what Zernio bills;
        `metaUSD` is the WhatsApp per-minute charge Meta bills directly to
        your WABA (display only, never billed by Zernio).

        Optional `groupBy` returns a breakdown by UTC day, by your number, or
        by channel. Defaults to the last 30 days.
      security:
        - bearerAuth: []
      parameters:
        - { name: since, in: query, schema: { type: string, format: date-time }, description: Start of the window (inclusive). Default 30 days before `until`. }
        - { name: until, in: query, schema: { type: string, format: date-time }, description: End of the window (exclusive). Default now. }
        - { name: channel, in: query, schema: { type: string, enum: [whatsapp, pstn] } }
        - { name: number, in: query, schema: { type: string }, description: "Scope to calls involving this number (typically one of YOUR numbers). E.164, leading + optional." }
        - { name: groupBy, in: query, schema: { type: string, enum: [day, number, channel] } }
      responses:
        '200':
          description: Usage totals (+ breakdown when groupBy is set).
          content:
            application/json:
              schema:
                type: object
                properties:
                  since: { type: string, format: date-time }
                  until: { type: string, format: date-time }
                  groupBy: { type: [string, "null"], enum: [day, number, channel, null] }
                  totals:
                    type: object
                    properties:
                      calls: { type: integer }
                      answered: { type: integer }
                      minutes: { type: number }
                      billableUSD: { type: number, description: What Zernio bills for these calls. }
                      metaUSD: { type: number, description: "WhatsApp only: Meta's per-minute charge, billed by Meta directly to your WABA. Display only." }
                  groups:
                    type: array
                    description: Present (possibly empty) when `groupBy` is set.
                    items:
                      type: object
                      properties:
                        key: { type: string, description: "The group key: a `YYYY-MM-DD` UTC day, one of your numbers, or a channel." }
                        calls: { type: integer }
                        answered: { type: integer }
                        minutes: { type: number }
                        billableUSD: { type: number }
                        metaUSD: { type: number }
        '400': { description: since must be before until }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/usage/sms:
    get:
      operationId: getSmsUsage
      tags: [Usage]
      summary: SMS usage (volumes)
      description: |
        Aggregated SMS/MMS volumes across your numbers: sent, received, and
        total message counts, with an optional breakdown by UTC day or by
        number. Defaults to the last 30 days.

        Volumes only, deliberately: SMS cost is carrier-rated asynchronously
        and billed to your invoice, so per-message cost is not available here.
        Calling usage (GET /v1/usage/calls) does include billable cost.
      security:
        - bearerAuth: []
      parameters:
        - { name: since, in: query, schema: { type: string, format: date-time }, description: Start of the window (inclusive). Default 30 days before `until`. }
        - { name: until, in: query, schema: { type: string, format: date-time }, description: End of the window (exclusive). Default now. }
        - { name: number, in: query, schema: { type: string }, description: "Scope to one of YOUR SMS-enabled numbers (E.164, leading + optional)." }
        - { name: groupBy, in: query, schema: { type: string, enum: [day, number] } }
      responses:
        '200':
          description: Volume totals (+ breakdown when groupBy is set).
          content:
            application/json:
              schema:
                type: object
                properties:
                  since: { type: string, format: date-time }
                  until: { type: string, format: date-time }
                  groupBy: { type: [string, "null"], enum: [day, number, null] }
                  totals:
                    type: object
                    properties:
                      sent: { type: integer }
                      received: { type: integer }
                      total: { type: integer }
                  groups:
                    type: array
                    description: Present (possibly empty) when `groupBy` is set.
                    items:
                      type: object
                      properties:
                        key: { type: string, description: "A `YYYY-MM-DD` UTC day or one of your numbers." }
                        sent: { type: integer }
                        received: { type: integer }
                        total: { type: integer }
        '400': { description: since must be before until }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "`number` doesn't match any of your SMS-enabled numbers" }

  /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: source
          in: query
          schema: { type: string, enum: [zernio, external], default: zernio }
          description: >-
            Which collection to read. `zernio` (default) returns posts authored
            through Zernio. `external` returns posts synced from the platform
            (existing/historical posts that were published outside Zernio).
            Combine with `accountId` and paginate via `page`/`limit` to walk the
            full synced history (we keep up to the last ~12 months per account).
        - 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.

        ## Idempotency

        Two layers of duplicate-protection apply, so safe-to-retry callers (network blips, n8n / Zapier retries, etc.) don't accidentally double-post.

        **1. Same-request idempotency (5-minute window).**
        Pass an `x-request-id` header to mark a logical request. If a second request arrives with the same `x-request-id` while the first is in-flight (or within ~5 minutes of completion), we return **HTTP 200** with the original post in the `existingPost` field — no new post is created. The official Zernio SDKs auto-generate a unique `x-request-id` per call. If you're using a generic HTTP client (curl, n8n's HTTP node, Zapier, custom code), either:
        - Set a unique `x-request-id` per logical call (recommended — UUIDv4 is fine)
        - Or simply omit the header — we'll treat each request as new

        **Common pitfall**: if your workflow tool uses a single execution-level request ID and reuses it across multiple HTTP nodes (e.g. one ID for the whole run, shared across 6 different platform calls), every call after the first will look like a retry of the first and return its post. Generate a fresh ID per node.

        **2. Content-hash dedup (24-hour window).**
        Independently, we hash `(platform, accountId, content + media URLs)` and reject duplicates within 24 hours with **HTTP 409**. This catches genuine "same content posted twice to the same account" cases regardless of `x-request-id`. Returns `error`, `accountId`, `platform`, and `existingPostId` so you can find the original. To intentionally re-post identical content within 24h, change something (the caption, the media, the account) — the dedup is keyed on the full content fingerprint.

        Order: same-`x-request-id` retries (200) are checked first; if no idempotency match, the content-hash dedup (409) runs.
      parameters:
        - name: x-request-id
          in: header
          required: false
          schema: { type: string, format: uuid }
          description: |
            Optional client-generated request identifier for safe retry (idempotency). When two requests carry the same value, the second is treated as a retry of the first and returns the original post (HTTP 200) instead of creating a duplicate. Window is ~5 minutes from the first request. Generate a UUID per logical call. SDKs do this automatically; HTTP clients should set it themselves or omit it. See the operation description for the full idempotency contract.
      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: { $ref: '#/components/schemas/MediaItem' }
                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
                    required: [platform, accountId]
                    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: { $ref: '#/components/schemas/MediaItem' }
                      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
              facebookCarousel:
                summary: Facebook multi-link carousel post
                description: |
                  Posts a 2-5 card carousel where each image has its own click-through link
                  and optional headline. mediaItems and carouselCards must have the same
                  length, in the same order. Images only (no video cards).
                value:
                  content: "Check out our new inventory"
                  mediaItems:
                    - url: "https://cdn.example.com/car-1.jpg"
                      type: image
                    - url: "https://cdn.example.com/car-2.jpg"
                      type: image
                    - url: "https://cdn.example.com/car-3.jpg"
                      type: image
                  platforms:
                    - platform: facebook
                      accountId: "64e1f0a9e2b5af0012ab34cd"
                  publishNow: true
                  facebookSettings:
                    carouselLink: "https://example.com/inventory"
                    carouselCards:
                      - link: "https://example.com/inventory/car-1"
                        name: "2024 Sedan"
                        description: "Low miles"
                      - link: "https://example.com/inventory/car-2"
                        name: "2023 SUV"
                        description: "Certified pre-owned"
                      - link: "https://example.com/inventory/car-3"
                        name: "2024 Truck"
                        description: "Loaded"
              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. Returned when the requested post matches an existing one on `(platform, accountId, content-hash)` within the last 24 hours, AND the request was NOT an `x-request-id` retry of an in-flight call. Distinct from same-`x-request-id` retries (which return HTTP 200 with the original post — see operation description for the idempotency contract).

            Body fields:
            - `error` — human-readable message
            - `details.accountId` — the account that already has this content
            - `details.platform` — the platform that already has this content
            - `details.existingPostId` — Zernio `_id` of the original post

            To intentionally re-post identical content within 24h, vary the content fingerprint (change the caption, swap a media item, or use a different account). To avoid 409s caused by retry loops, set a unique `x-request-id` per logical request — see `parameters.x-request-id` above.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: "This exact content is already scheduled, publishing, or was 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 }
            X-RateLimit-Reset:
              description: Unix timestamp (seconds since epoch) when the next slot frees up in the sliding window
              schema: { type: integer }
  /v1/posts/sync-external:
    post:
      operationId: syncExternalPosts
      tags: [Analytics]
      summary: Sync an external post
      description: |
        Fetch an account's latest external posts (published directly on the platform, not through Zernio) on demand, so a just-published post is retrievable within seconds instead of waiting for the background sync (which refreshes each account at most every ~90 minutes).

        Primary use case: verifying a submitted post. When a user publishes on the platform and immediately pastes the post URL into your app, call this with `accountId` plus `url` (or `postId`) to confirm the post exists and return its metadata.

        Behavior:
        - We check our stored copy first and return immediately if the post is already known (no platform call).
        - Otherwise we fetch the account's latest posts live from the platform, then match and return the submitted post.
        - Requests are debounced per account (~15s): if the account was just synced, the live fetch is skipped.

        `accountId` is required — a post URL or id alone cannot be resolved to an account, and the account must be connected to Zernio (we use its token to read the platform). Supported for every platform with a listing API (Instagram, Facebook, TikTok, YouTube, X, Threads, Pinterest, Reddit, Bluesky, Google Business, and LinkedIn organization accounts; LinkedIn personal accounts are not supported).

        `url` accepts any format the platform uses (e.g. `instagram.com/p/…`, `instagram.com/reel/…`, `youtu.be/…`, `youtube.com/shorts/…`, `tiktok.com/@user/video/…`, and `vm.tiktok.com` short links). Pass `postId` (the platform media/video id) as an alternative locator.

        Note: post-level analytics (reach, impressions) still carry the platform's own delay (e.g. ~24h on Instagram). This endpoint confirms the post exists and returns its metadata plus basic engagement (likes, comments), not delayed insights.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId:
                  type: string
                  description: SocialAccount ID whose posts to sync. Must be connected to Zernio.
                url:
                  type: string
                  description: The post URL to locate. Optional. Provide `url` or `postId` to return a specific post; omit both to just refresh and return the account's recent posts.
                postId:
                  type: string
                  description: The platform post/media/video id to locate, as an alternative to `url`. Optional.
      responses:
        '200':
          description: |
            Sync result. When `url` or `postId` is provided, returns the matched post (or `found: false`). When neither is provided, returns the account's freshly-synced recent posts.
          content:
            application/json:
              schema:
                type: object
                properties:
                  synced:
                    type: object
                    properties:
                      postsFound: { type: integer, description: Posts returned by the platform listing during the on-demand sync }
                      postsSynced: { type: integer, description: Posts inserted or updated in Zernio }
                      skipped: { type: boolean, description: "True when no live fetch ran: the post was already stored, or the account was synced within the debounce window" }
                  found:
                    type: boolean
                    description: Present only when a locator (`url`/`postId`) was provided — whether the post was found.
                  post:
                    description: The matched external post, or null when not found. Present only when a locator was provided.
                    oneOf:
                      - $ref: '#/components/schemas/ExternalPostSummary'
                      - type: 'null'
                  posts:
                    type: array
                    description: The account's recent external posts. Present only when no locator was provided.
                    items:
                      $ref: '#/components/schemas/ExternalPostSummary'
        '400':
          description: Invalid request (e.g. `accountId` missing or malformed)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
        '404':
          description: Account not found (or not owned by the authenticated user)
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ErrorResponse'
  /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. Draft, scheduled, failed, partial, and cancelled posts can be edited.
        Published posts can only have their recycling config updated.

        To promote a draft to scheduled, send `isDraft: false` together with `scheduledFor` (or `publishNow: true`,
        or `queuedFromProfile`). If `isDraft` is omitted the post keeps its current draft status, so sending only
        `scheduledFor` to a draft returns 200 but the post remains a draft.
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                title: { type: string }
                content: { type: string }
                mediaItems:
                  type: array
                  items: { $ref: '#/components/schemas/MediaItem' }
                platforms:
                  type: array
                  description: Target platforms and accounts for this post. Each item must include platform and accountId.
                  items:
                    type: object
                    required: [platform, accountId]
                    properties:
                      platform: { type: string, example: twitter }
                      accountId: { type: string }
                      customContent:
                        type: string
                        description: Platform-specific text override.
                      customMedia:
                        type: array
                        items: { $ref: '#/components/schemas/MediaItem' }
                      scheduledFor:
                        type: string
                        format: date-time
                        description: Optional per-platform scheduled time override.
                      platformSpecificData:
                        type: object
                        additionalProperties: true
                scheduledFor: { type: string, format: date-time }
                publishNow: { type: boolean, default: false }
                isDraft:
                  type: boolean
                  description: When omitted, the post keeps its current draft status. Send `false` to promote a draft to scheduled (combined with `scheduledFor`, `publishNow`, or a queue).
                timezone: { type: string }
                visibility: { type: string, enum: [public, private, unlisted] }
                tags:
                  type: array
                  items: { type: string }
                hashtags:
                  type: array
                  items: { type: string }
                mentions:
                  type: array
                  items: { type: string }
                crosspostingEnabled: { type: boolean }
                metadata: { type: object, additionalProperties: true }
                queuedFromProfile:
                  type: string
                  description: Profile ID to schedule via queue.
                queueId:
                  type: string
                  description: Specific queue ID to use when scheduling via queue.
                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. Returned when every row succeeded (or every row failed).
            A mix of successes and failures returns `207` instead, with the same body shape.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkUploadResult'
              example:
                total: 1
                valid: 1
                invalid: 0
                results:
                  - rowIndex: 1
                    ok: true
                    createdPostId: "69df8c12f102e11169c53cd3"
                warnings: []
        '207':
          description: |
            Partial success: some rows were created and some failed. Body is identical in
            shape to the `200` response. Inspect each entry in `results` (`ok` plus `errors`)
            to see which rows failed and why.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BulkUploadResult'
              example:
                total: 1
                valid: 0
                invalid: 1
                results:
                  - rowIndex: 1
                    ok: false
                    errors:
                      - "unknown_profile:693ae023a552465131f3bdca1"
                      - "no_account_for_platform:tiktok"
                warnings: []
        '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' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '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:
                $ref: '#/components/schemas/ProfileGetResponse'
              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:
                $ref: '#/components/schemas/ProfileUpdateResponse'
              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. Active connected accounts block deletion
        (returns 400) - disconnect them first. Any remaining disconnected
        accounts and provisioned WhatsApp numbers are moved to another of your
        profiles (a new one is created only if needed), never deleted.
      parameters:
        - name: profileId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Deleted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/ProfileDeleteResponse'
              example:
                message: "Profile deleted successfully"
        '400': { description: Profile has active connected accounts; disconnect them first }
        '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: status
          in: query
          schema:
            type: string
            enum: [connected, disconnected]
          description: |
            Filter accounts by connection status. `connected` returns healthy accounts; `disconnected` returns accounts that need reconnection (per the same reconnection check surfaced in the dashboard). Omit to return accounts in any status. When combined with page/limit, pagination totals reflect the filtered result set.
        - 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:
                $ref: '#/components/schemas/AccountsListResponse'
              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:
                $ref: '#/components/schemas/FollowerStatsResponse'
              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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
          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.

        For X/Twitter accounts on usage-based billing, also accepts an `xCapabilities`
        object to toggle background API operations that incur X API pass-through costs.
        Both fields are opt-in (default `false`) — when off, no analytics syncs or DM
        polling are performed for that account, and no API call is metered for those
        operations. Publishing and deleting posts are always available regardless of
        these toggles. Setting `xCapabilities` on a non-X account returns 400.
      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 }
                xCapabilities:
                  type: object
                  description: |
                    X/Twitter only. Per-account opt-in toggles for background API
                    operations that incur X API pass-through costs. Each call is
                    billed via Metronome at the X tier rate. Either field can be
                    sent independently; omitted fields are unchanged.
                  properties:
                    analytics:
                      type: boolean
                      description: |
                        Enable periodic analytics reads (impressions, likes, etc.)
                        for this X account. Each X API call is metered as
                        `posts_read` and billed pass-through (~$0.005/call at the
                        time of writing — actual rate depends on X's pricing tier).
                    inbox:
                      type: boolean
                      description: |
                        Enable DM polling and inbox sync for this X account. DM
                        reads are metered as `dm_event_read` (~$0.010/call) and
                        DM sends as `dm_interaction_create` (~$0.015/call), both
                        billed pass-through. DM sends fire only on user-initiated
                        actions; reads/polling fire only when this flag is true.
            example:
              displayName: "Acme Corporation Official"
              xCapabilities:
                analytics: true
                inbox: false
      responses:
        '200':
          description: Updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  username: { type: string }
                  displayName: { type: string }
                  xCapabilities:
                    type: object
                    description: |
                      Echo of the resulting `xCapabilities` state, returned only
                      when the request body included an `xCapabilities` object.
                    properties:
                      analytics: { type: boolean }
                      inbox: { type: boolean }
              example:
                message: "Account updated successfully"
                username: "@acmecorp"
                displayName: "Acme Corporation Official"
                xCapabilities:
                  analytics: true
                  inbox: false
        '400': { description: Invalid request (e.g. xCapabilities on a non-X account) }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      operationId: moveAccountToProfile
      tags: [Accounts]
      summary: Move account to another profile
      description: |
        Moves a connected social account to a different profile owned by the same
        user. The target profile must belong to the same user as the account.

        For API keys restricted to specific profiles, BOTH the source account's
        current profile AND the target profile must be in the key's allowed set.
        Calls with a target profile outside the key's scope return 403.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId]
              properties:
                profileId:
                  type: string
                  description: Target profile ID (must be a valid ObjectId and owned by the same user as the account).
            example:
              profileId: "65f1a2b3c4d5e6f7a8b9c0d1"
      responses:
        '200':
          description: Account moved
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  profileId: { type: string }
              example:
                message: "Account updated successfully"
                profileId: "65f1a2b3c4d5e6f7a8b9c0d1"
        '400': { description: Missing or invalid profileId }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: API key does not have access to the source account or target profile }
        '404': { description: Account or target profile not found }
    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, whatsapp]
        - 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.
                role:
                  type: string
                  enum: [admin, member, billing_admin, viewer]
                  default: member
                  description: Org role granted to the invitee. Defaults to 'member'. 'admin' can manage the team (invite/remove members, change roles and access) and billing, but not ownership transfer or account deletion. 'billing_admin' (displayed as Billing Manager) manages billing only. 'viewer' creates a read-only member who can view everything in their profile scope but cannot perform any content mutation (publish, edit, delete, connect accounts).
                readOnly:
                  type: boolean
                  deprecated: true
                  description: Deprecated. Use role 'viewer' instead. When true, the invite is created with role 'viewer'. Cannot be combined with role 'billing_admin' or 'admin'.
            example:
              scope: "profiles"
              profileIds:
                - "64f0a1b2c3d4e5f6a7b8c9d0"
                - "64f0a1b2c3d4e5f6a7b8c9d1"
              role: "member"
      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, whatsapp]
          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' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '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. If the ads account already exists, returns alreadyConnected: true.
          - tiktok: accountId is OPTIONAL. With accountId, the new tiktokads account links to that posting account (parentAccountId set) — Spark Ads + standalone ads using the posting TT_USER identity become available. Without accountId, ads-only mode kicks in: the new tiktokads account has parentAccountId=null and standalone ads use a synthetic CUSTOMIZED_USER ("Brand Identity"); Spark Ads are unavailable because TikTok requires a posting account for them. The Brand Identity is configured separately via PATCH /v1/connect/tiktok-ads (or inline on POST /v1/ads/create via the brandIdentity field).
          - twitter (X Ads): accountId is REQUIRED. There's no ads-only mode — tweets need to be authored by a real X user.

        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 `twitter` (X Ads). Optional for `tiktok` —
            omit to enter ads-only mode (no TikTok posting account linked; ad creation uses
            a Brand Identity instead of a TT_USER). Ignored for same-token (`facebook`,
            `instagram`, `linkedin`, `pinterest`) and standalone (`googleads`) 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)
        - name: adAccountId
          in: query
          schema: { type: string }
          description: |
            Scope ad sync to a single platform ad account. Without this param,
            sync covers every ad account the connected token can see. Supported
            on `facebook`/`instagram` (Meta, `act_<digits>`), `linkedin` (bare
            numeric sponsored-account id), `googleads` (bare customer id digits)
            and `twitter` (X Ads, base36 account id). `tiktok` scopes advertisers
            at OAuth and `pinterest` has no ads discovery, so both ignore it.
            Meta ids are additionally validated against the connected token;
            unreachable IDs return 400. Setting a scope also removes already
            synced ads from de-scoped ad accounts. For multiple accounts use
            `adAccountIds` instead.
          example: act_1330190928038136
        - name: adAccountIds
          in: query
          style: form
          explode: true
          schema:
            type: array
            items: { type: string }
          description: |
            Scope ad sync to multiple platform ad accounts (same platform
            support and id shapes as `adAccountId`). Repeat the param
            (`?adAccountIds=act_1&adAccountIds=act_2`) or comma-separate
            (`?adAccountIds=act_1,act_2`). Persisted server-side; latest call
            wins, and de-scoped ad accounts have their synced ads removed.
            Omitting both `adAccountId` and `adAccountIds` keeps any previously
            persisted scope unchanged.
      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 }
                      scopedAdAccountIds:
                        type: array
                        items: { type: string }
                        description: |
                          Echo of the persisted ad-account scope when the caller passed
                          `adAccountId` / `adAccountIds`. Omitted when no scope is set.
                        example: ["act_1330190928038136"]
                  - 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 X Ads"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: "Ads access required (Ads add-on on legacy plans, included on usage-based plans), or no access to profile"
        '404':
          description: "Profile or posting account not found"

  /v1/connect/tiktok-ads:
    patch:
      operationId: configureTikTokAdsBrandIdentity
      tags: [Connect]
      summary: Set TikTok brand identity
      description: |
        Set or update the Brand Identity (display name + avatar) for a
        `tiktokads` SocialAccount. TikTok requires every ad to carry an
        `identity_id + identity_type` pair. The Brand Identity is the
        CUSTOMIZED_USER alternative to attributing ads to a real @username
        (TT_USER). This route uploads the supplied image to TikTok, creates
        the identity via `/v2/identity/create/`, and caches the resulting
        `identity_id` on the account so subsequent `POST /v1/ads/create`
        calls can opt into it via `identityType: 'CUSTOMIZED_USER'`.

        Configurable on every `tiktokads` account, including linked-mode ones
        (those with a posting account on the same profile). Configuration is
        idempotent and harmless when posting is also connected: the default
        ad-create path still prefers TT_USER, and CUSTOMIZED_USER is only used
        per-ad when the caller explicitly opts in.

        TikTok identities are immutable post-creation. Re-saving creates a new
        identity on TikTok and swaps the cached id; the old identity stays
        orphaned on TikTok's side (harmless, no billing impact).

        Alternative: pass `brandIdentity` directly on `POST /v1/ads/create` to
        configure on first ad creation in a single round-trip.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, displayName, imageUrl]
              properties:
                accountId:
                  type: string
                  description: SocialAccount ID of the `tiktokads` account.
                displayName:
                  type: string
                  minLength: 1
                  maxLength: 40
                  description: Brand name shown above the ad on TikTok.
                imageUrl:
                  type: string
                  format: uri
                  description: Public URL of a square brand image (≥98×98 px, JPG/PNG, max 5 MB). Used as the brand avatar on the ad.
      responses:
        '200':
          description: Brand identity configured (or updated)
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean, example: true }
                  identityId: { type: string, description: "The TikTok-assigned identity_id, cached on the account." }
                  displayName: { type: string }
        '400':
          description: "Missing fields, invalid lengths, account is in linked mode, or no advertiser found on the account"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: TikTok Ads account not found
        '500':
          description: Failed to create the TikTok identity (TikTok API error)

  /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.
        - name: search
          in: query
          required: false
          schema: { type: string }
          description: >
            Free-text search on the business name, applied server-side by Google.
            Use this for accounts that own many locations (the response is bounded,
            see hasMore) so the user can find a specific location without loading
            the full list.
        - name: filter
          in: query
          required: false
          schema: { type: string }
          description: >
            Raw Google Business Information API filter expression (advanced;
            takes precedence over search). Supports fields such as title, storeCode,
            storefront_address.postal_code, labels and categories, e.g.
            storeCode="LH279411". See Google's "Work with location data" guide.
      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 }
                        storeCode: { type: string, description: Store code set on the location in Google Business Profile (if any) }
                  hasMore:
                    type: boolean
                    description: >
                      True when more locations exist than were returned (the list is
                      bounded). Prompt the user to narrow the result set with search.
              example:
                locations:
                  - id: "9281089117903930794"
                    name: "My Coffee Shop"
                    accountId: "accounts/113303573364907650416"
                    accountName: "My Business Account"
                    address: "123 Main St, City, Country"
                    category: "Coffee shop"
                    storeCode: "CS-001"
                hasMore: false
        '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
                accountId:
                  type: string
                  description: >
                    Optional but recommended. The Google Business Account resource name
                    ("accounts/123") that owns the selected location (returned per-location
                    by GET /v1/connect/googlebusiness/locations). When provided, the location
                    is resolved directly instead of by enumerating the account, which is
                    required for accounts that own many locations. Omit only for small accounts.
                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"
              accountId: "accounts/113303573364907650416"
              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, "null"] }
                            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, "null"]
                          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, "null"], 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-verifications:
    get:
      operationId: getGoogleBusinessVerifications
      tags: [GMB Verifications]
      summary: Get verification state
      description: >-
        Returns the location's Voice of Merchant state plus its verification
        history. `voiceOfMerchantState.hasVoiceOfMerchant` tells you whether the
        listing is verified and published; when it is false, `verify` reports
        whether a verification is already pending. Each entry in `verifications`
        has a `state` of PENDING, COMPLETED, or FAILED.
      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: Verification state fetched successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  accountId: { type: string }
                  locationId: { type: string }
                  voiceOfMerchantState:
                    type: object
                    description: Raw Voice of Merchant state from Google.
                    properties:
                      hasVoiceOfMerchant: { type: boolean, description: "True when the listing is verified and published (eligible to surface reviews, edits, etc.)." }
                      hasBusinessAuthority: { type: boolean, description: True when the authenticated user has owner/manager authority over the listing. }
                      verify:
                        type: object
                        description: Present when verification is the path to Voice of Merchant.
                        properties:
                          hasPendingVerification: { type: boolean, description: True when a verification is already in progress. }
                  verifications:
                    type: array
                    description: Verification history, newest first. Empty when none exist.
                    items:
                      type: object
                      properties:
                        name: { type: string, description: 'Resource name, e.g. "locations/123/verifications/0T1776879124712". The last segment is the verificationId.' }
                        method: { type: string, enum: [ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER], description: Method used (omitted on some entries). }
                        state: { type: string, enum: [PENDING, COMPLETED, FAILED] }
                        createTime: { type: string, format: date-time }
              example:
                success: true
                accountId: "64e1f0a9e2b5af0012ab34cd"
                locationId: "16699729527667179850"
                voiceOfMerchantState:
                  hasVoiceOfMerchant: false
                  hasBusinessAuthority: true
                  verify:
                    hasPendingVerification: false
                verifications:
                  - name: "locations/16699729527667179850/verifications/4T1775504407480"
                    method: "SMS"
                    state: "FAILED"
                    createTime: "2026-04-06T19:40:07.480Z"
        '400':
          description: Not a Google Business account or missing location
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized or token invalid
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      operationId: startGoogleBusinessVerification
      tags: [GMB Verifications]
      summary: Start a verification
      description: >-
        Starts a verification for the location. This is a mutating action:
        depending on `method`, Google mails a postcard, places a call, or sends
        an SMS/email to the business. Submit the resulting code with POST
        /gmb-verifications/{verificationId}/complete. Use POST
        /gmb-verifications/options first to discover which methods are eligible.
      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.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [method]
              properties:
                method: { type: string, enum: [ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER], description: The verification method. Selects which method-specific field below is required. }
                languageCode: { type: string, example: "en-US" }
                phoneNumber: { type: string, description: For PHONE_CALL / SMS. }
                emailAddress: { type: string, description: For EMAIL. }
                mailerContact: { type: object, description: For ADDRESS (postcard) verification. }
                context: { type: object, description: ServiceBusinessContext (e.g. service address). Required for service-area businesses. }
            example:
              method: "SMS"
              languageCode: "en-US"
              phoneNumber: "+14155550123"
      responses:
        '200':
          description: Verification started
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  accountId: { type: string }
                  locationId: { type: string }
                  verification:
                    type: object
                    properties:
                      name: { type: string }
                      method: { type: string, enum: [ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER] }
                      state: { type: string, enum: [PENDING, COMPLETED, FAILED] }
                      createTime: { type: string, format: date-time }
        '400':
          description: Invalid request (e.g. wrong field for the chosen method, or Google rejected it)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized or token invalid
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/accounts/{accountId}/gmb-verifications/options:
    post:
      operationId: fetchGoogleBusinessVerificationOptions
      tags: [GMB Verifications]
      summary: Fetch verification options
      description: >-
        Reports the verification methods Google currently offers for the
        location. Non-mutating (nothing is sent to the business). `languageCode`
        is required; service-area ("CUSTOMER_LOCATION_ONLY") businesses also
        require `context.address`, otherwise Google returns 400.
      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.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [languageCode]
              properties:
                languageCode: { type: string, example: "en-US" }
                context: { type: object, description: ServiceBusinessContext. Required for service-area businesses (must include the service address). }
            example:
              languageCode: "en-US"
      responses:
        '200':
          description: Verification options fetched
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  accountId: { type: string }
                  locationId: { type: string }
                  options:
                    type: array
                    items:
                      type: object
                      properties:
                        verificationMethod: { type: string, enum: [ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER] }
                        phoneNumber: { type: string, description: Present for PHONE_CALL / SMS. }
        '400':
          description: Invalid request (e.g. missing service business context, or missing languageCode)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized or token invalid
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/accounts/{accountId}/gmb-verifications/{verificationId}/complete:
    post:
      operationId: completeGoogleBusinessVerification
      tags: [GMB Verifications]
      summary: Complete a verification
      description: >-
        Completes a PENDING verification by submitting the PIN/code Google sent
        the business (postcard code, SMS PIN, etc.). On success the verification
        moves to COMPLETED.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
          description: The Zernio account ID (from /v1/accounts)
        - name: verificationId
          in: path
          required: true
          schema: { type: string }
          description: The last segment of a verification `name` from GET /gmb-verifications.
        - 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: [pin]
              properties:
                pin: { type: string, description: The code Google sent to the business. }
            example:
              pin: "123456"
      responses:
        '200':
          description: Verification completed
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  accountId: { type: string }
                  locationId: { type: string }
                  verification:
                    type: object
                    properties:
                      name: { type: string }
                      method: { type: string, enum: [ADDRESS, EMAIL, PHONE_CALL, SMS, AUTO, VETTED_PARTNER] }
                      state: { type: string, enum: [PENDING, COMPLETED, FAILED] }
                      createTime: { type: string, format: date-time }
        '400':
          description: Invalid request (e.g. wrong PIN or verification not pending)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized or token invalid
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404': { $ref: '#/components/responses/NotFound' }

  /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, "null"]
                    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, "null"], description: Business name as set in GBP }
                      placeId: { type: [string, "null"], description: Google Maps Place ID for this location }
                      reviewUrl: { type: [string, "null"], description: Public "write a review" URL Google generates for this place }
                      mapsUri: { type: [string, "null"], 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-attribute-metadata:
    get:
      operationId: getGmbAttributeMetadata
      tags: [GMB Attributes]
      summary: Get attribute metadata
      description: |
        Returns metadata about which Google Business Profile attributes are available for
        a location or business category. Use this endpoint to discover valid attribute names,
        value types, and allowed enum values before reading or writing via gmb-attributes.

        Two mutually exclusive query modes:

        **Location mode**: pass `locationId` (or rely on the account's stored `selectedLocationId`).
        Google returns attributes valid for that specific location.

        **Category mode**: pass `categoryName` (must start with `categories/`) and `regionCode`.
        Google returns attributes valid for that category across the given region.
        `languageCode` is optional in category mode.

        Both modes support `pageSize` and `pageToken` for pagination.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
        - name: locationId
          in: query
          schema: { type: string }
          description: >
            GBP location ID (e.g. "6257659026299438786"). If omitted, uses the account's
            stored selectedLocationId. Mutually exclusive with categoryName.
        - name: categoryName
          in: query
          schema: { type: string }
          description: >
            Category resource name, must start with "categories/" (e.g.
            "categories/gcid:plumber"). Required together with regionCode. Mutually
            exclusive with locationId.
        - name: regionCode
          in: query
          schema: { type: string }
          description: >
            BCP-47 region code (e.g. "US", "ES"). Required when categoryName is provided.
        - name: languageCode
          in: query
          schema: { type: string }
          description: >
            BCP-47 language code for display names (e.g. "en", "es"). Optional when
            categoryName is provided. Omitted from the Google call when not supplied.
        - name: pageSize
          in: query
          schema: { type: integer, minimum: 1, maximum: 200 }
          description: Maximum number of attribute metadata items to return. Google defaults to 200.
        - name: pageToken
          in: query
          schema: { type: string }
          description: Pagination token from a previous response's nextPageToken field.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Attribute metadata fetched successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  accountId: { type: string }
                  locationId:
                    type: string
                    description: Only present in location mode.
                  attributeMetadata:
                    type: array
                    items:
                      type: object
                      properties:
                        parent:
                          type: string
                          description: Resource name of the attribute (e.g. "attributes/has_delivery").
                        valueType:
                          type: string
                          description: Value type (e.g. BOOL, ENUM, URL, REPEATED_ENUM).
                        displayName:
                          type: string
                          description: Localized human-readable attribute name.
                        groupDisplayName:
                          type: string
                          description: Display name of the attribute group.
                        repeatable:
                          type: boolean
                          description: True if multiple values can be set simultaneously.
                        deprecated:
                          type: boolean
                          description: True if this attribute should no longer be used.
                        valueMetadata:
                          type: array
                          description: Possible enum values (for ENUM / REPEATED_ENUM types).
                          items:
                            type: object
                            properties:
                              value: { type: string }
                              displayName: { type: string }
                  nextPageToken:
                    type: string
                    description: Present when additional pages of results are available.
              example:
                success: true
                accountId: "64f1a2b3c4d5e6f7a8b9c0d1"
                locationId: "6257659026299438786"
                attributeMetadata:
                  - parent: "attributes/has_delivery"
                    valueType: "BOOL"
                    displayName: "Delivery"
                    groupDisplayName: "Service options"
                  - parent: "attributes/pay_credit_card_types_accepted"
                    valueType: "REPEATED_ENUM"
                    displayName: "Credit cards accepted"
                    valueMetadata:
                      - value: "visa"
                        displayName: "Visa"
                      - value: "mastercard"
                        displayName: "Mastercard"
        '400':
          description: Invalid request (mixed modes, missing required params, wrong platform, or Google returned 4xx)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Access token is invalid or revoked. Reconnect the Google Business Profile account.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404':
          description: Account not found
          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.
        Returns a flat list of individual reviews, each tagged with its review resource name.
        Note: this endpoint does not return aggregate metrics (averageRating / totalReviewCount).
        For those, use the single-location GET /gmb-reviews endpoint.
      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:
                        name: { type: string, description: Full review resource name (accounts/*/locations/*/reviews/*) }
                        review: { type: object, description: "The review object (reviewId, starRating, comment, reviewer, createTime, updateTime, reviewReply)" }
                  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/accounts/{accountId}/gmb-reviews/{reviewId}/reply:
    post:
      operationId: replyToGoogleBusinessReview
      tags: [GMB Reviews]
      summary: Reply to a review
      description: |
        Posts (or updates) the business owner reply to a Google Business review.
        The reply is associated with the account's currently selected location (set via /v1/accounts/{accountId}/gmb-locations).
        Calling this endpoint a second time on the same review overwrites the previous reply (PUT semantics on Google's side).
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
          description: The Zernio account ID (from /v1/accounts)
        - name: reviewId
          in: path
          required: true
          schema: { type: string }
          description: The review ID portion (e.g. "AIe9_BGx1234567890"), not the full resource name
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [comment]
              properties:
                comment:
                  type: string
                  description: The reply text to post on the review. Must be non-empty.
            example:
              comment: "Thank you for your kind words, we really appreciate it!"
      responses:
        '200':
          description: Reply posted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  reviewId: { type: string }
                  platform: { type: string, example: googlebusiness }
              example:
                success: true
                reviewId: "AIe9_BGx1234567890"
                platform: "googlebusiness"
        '400':
          description: Invalid request, missing comment, non-GBP account, or account missing location metadata
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
              example:
                error: "comment is required and must be a non-empty string"
        '401':
          description: Unauthorized or token invalid (account must be reconnected)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
              example:
                error: "Access token invalid. Please reconnect your Google Business Profile account."
                code: "token_invalid"
        '404': { $ref: '#/components/responses/NotFound' }
        '500':
          description: Failed to post reply
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
    delete:
      operationId: deleteGoogleBusinessReviewReply
      tags: [GMB Reviews]
      summary: Delete a review reply
      description: Removes the business owner reply from a Google Business review. The review itself remains.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
          description: The Zernio account ID (from /v1/accounts)
        - name: reviewId
          in: path
          required: true
          schema: { type: string }
          description: The review ID portion (e.g. "AIe9_BGx1234567890"), not the full resource name
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Reply deleted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  message: { type: string }
                  platform: { type: string, example: googlebusiness }
              example:
                success: true
                message: "Reply deleted successfully"
                platform: "googlebusiness"
        '400':
          description: Invalid request, non-GBP account, or account missing location metadata
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized or token invalid (account must be reconnected)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
              example:
                error: "Access token invalid. Please reconnect your Google Business Profile account."
                code: "token_invalid"
        '404': { $ref: '#/components/responses/NotFound' }
        '500':
          description: Failed to delete reply
          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.

        **Scope**: This endpoint is used for LinkedIn organizations, Snapchat profiles, and Pinterest boards, where the selection list is too large to fit in URL params. The redirect carries a `pendingDataToken` instead of the full payload; the response includes the corresponding selection array (e.g. `boards` for Pinterest). WhatsApp, Facebook, Google Business and other platforms pass selection state directly via URL query params on the redirect (`profileId`, `tempToken`, `step`), no pending record is created, so this endpoint will return 404 for those flows. Use the platform-specific selection endpoint instead (e.g. `/v1/connect/whatsapp/select-phone-number`).

        Token is one-time use and 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/whatsapp/select-phone-number:
    get:
      operationId: listWhatsAppPhoneNumbers
      tags: [Connect]
      summary: List numbers for selection
      description: |
        Fetch the WhatsApp phone numbers available across the user's WhatsApp Business Accounts (WABAs) after a headless OAuth flow.

        WhatsApp OAuth grants access at the WABA level. When a connected WABA has 2 or more phone numbers, you must call this endpoint to list them and then `POST /v1/connect/whatsapp/select-phone-number` to bind one to the Zernio profile. Single-phone WABAs auto-complete during the OAuth callback and never reach this endpoint.

        Use the `profileId` and `tempToken` returned in the headless redirect (`step=select_phone_number`).

        Alternative: if you already know `wabaId` and `phoneNumberId` (e.g. from Meta Business Suite), use `connectWhatsAppCredentials` instead, which skips this two-step flow.
      security:
        - bearerAuth: []
      parameters:
        - name: profileId
          in: query
          required: true
          schema: { type: string }
          description: The Zernio profile ID from the headless redirect
        - name: tempToken
          in: query
          required: true
          schema: { type: string }
          description: The temporary access token from the headless redirect
        - name: X-Connect-Token
          in: header
          required: false
          schema: { type: string }
          description: Alternative auth for API users' end customers (used when the bearer token is scoped to a different user)
      responses:
        '200':
          description: Phone numbers fetched successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  phoneNumbers:
                    type: array
                    items:
                      type: object
                      description: Phone number entry. Field names use Meta WhatsApp Cloud API snake_case (passed through unchanged); wabaId and wabaName are Zernio enrichment.
                      properties:
                        id: { type: string, description: Phone Number ID (Meta) }
                        display_phone_number: { type: string, description: E.164-formatted display number }
                        verified_name: { type: string, description: Meta-verified business name }
                        quality_rating: { type: string, description: 'GREEN, YELLOW, RED, or UNKNOWN' }
                        name_status: { type: string, description: 'APPROVED, PENDING_REVIEW, DECLINED, or NONE' }
                        messaging_limit_tier: { type: string, description: 'TIER_250, TIER_1K, TIER_10K, TIER_100K, or TIER_UNLIMITED' }
                        wabaId: { type: string, description: WhatsApp Business Account ID (Zernio enrichment) }
                        wabaName: { type: string, description: WABA display name (Zernio enrichment) }
              example:
                phoneNumbers:
                  - id: "1875844705851813"
                    display_phone_number: "+55 83 8793-2039"
                    verified_name: "Bioface"
                    quality_rating: "GREEN"
                    name_status: "APPROVED"
                    messaging_limit_tier: "TIER_1K"
                    wabaId: "317766992490131"
                    wabaName: "Bioface WABA"
        '400':
          description: Missing profileId or tempToken
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '500':
          description: Failed to fetch phone numbers (Meta API error, expired token, or insufficient permissions)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
    post:
      operationId: completeWhatsAppPhoneSelection
      tags: [Connect]
      summary: Complete number selection
      description: |
        Bind a specific WhatsApp phone number to the Zernio profile after the user picks one from `listWhatsAppPhoneNumbers`. Exchanges the short-lived OAuth token for a long-lived token, subscribes the WABA to webhooks, and creates the SocialAccount.
      security:
        - bearerAuth: []
      parameters:
        - name: X-Connect-Token
          in: header
          required: false
          schema: { type: string }
          description: Alternative auth for API users' end customers
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, phoneNumberId, wabaId, tempToken]
              properties:
                profileId: { type: string, description: The Zernio profile ID }
                phoneNumberId: { type: string, description: The selected phone number ID (from listWhatsAppPhoneNumbers) }
                wabaId: { type: string, description: The WABA ID containing the selected phone }
                tempToken: { type: string, description: The temporary access token from the headless redirect }
                userProfile:
                  type: object
                  description: Optional user profile data (passthrough)
                redirect_url:
                  type: string
                  description: Optional URL to receive the post-connection redirect target
            example:
              profileId: "6507a1b2c3d4e5f6a7b8c9d0"
              phoneNumberId: "1875844705851813"
              wabaId: "317766992490131"
              tempToken: "EAABsbCS...short-lived-token"
      responses:
        '200':
          description: Phone number connected successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  redirect_url: { type: string, description: 'Present only if redirect_url was provided in the request' }
                  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 }
              example:
                message: "WhatsApp phone number connected successfully"
                account:
                  accountId: "6507a1b2c3d4e5f6a7b8c9d0"
                  platform: "whatsapp"
                  username: "+55 83 8793-2039"
                  displayName: "Bioface"
                  isActive: true
                  selectedPhoneNumber: "+55 83 8793-2039"
        '400':
          description: Missing required fields (profileId, phoneNumberId, wabaId, or tempToken)
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Profile limit exceeded for the user's plan (PROFILE_LIMIT_EXCEEDED)
        '404':
          description: Selected phone number not found in the specified WABA
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '500':
          description: Failed to bind phone number
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /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 }
        - name: refresh
          in: query
          required: false
          schema: { type: boolean }
          description: >
            When true, bypasses the page cache and fetches fresh pages from Meta.
            Rate-limited server-side to 1 refresh per 60s. Pages no longer accessible
            to the connected account will be removed from the list on refresh.
      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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
          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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
        '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 access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
        '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}/instagram/stories:
    get:
      operationId: listInstagramStories
      tags: [Instagram]
      summary: List active Instagram stories
      description: |
        Returns the IG Business/Creator account's currently-active stories.
        Meta keeps stories live for 24h; expired stories are not returned.

        Limitations propagated from Meta (these are NOT bugs):
        - 24h window only
        - Live videos excluded
        - Reshared stories not returned
        - `mediaUrl` may be null if Meta flagged the story for copyright
        - `caption`, `likeCount`, `commentsCount` do not apply to story media
      parameters:
        - name: accountId
          in: path
          required: true
          description: The Instagram account ID
          schema: { type: string }
      responses:
        '200':
          description: Active stories
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      required: [id]
                      properties:
                        id: { type: string, description: Instagram media ID of the story. }
                        mediaType: { type: [string, "null"], description: "IMAGE / VIDEO / CAROUSEL_ALBUM" }
                        mediaProductType: { type: [string, "null"], description: Always 'STORY' for this endpoint. }
                        mediaUrl: { type: [string, "null"], description: Direct media URL. Null if Meta flagged the story for copyright. URL expires when the story expires. }
                        permalink: { type: [string, "null"], description: Public Instagram permalink to the story (only viewable while live). }
                        thumbnailUrl: { type: [string, "null"], description: Thumbnail URL for video stories. }
                        timestamp: { type: [string, "null"], format: date-time, description: When the story was posted. }
        '400': { description: Invalid request. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Instagram account not found. }

  /v1/accounts/{accountId}/instagram/stories/{storyId}/insights:
    get:
      operationId: getInstagramStoryInsights
      tags: [Instagram]
      summary: Get Instagram story insights
      description: |
        Returns metrics for a single story. The `source` field discriminates
        between three states:

        - `live` — fetched from Meta in real time (story is still active)
        - `cached` — fetched from a persisted `story_insights` webhook payload
          (story has expired but we received its final-state metrics from Meta)
        - `unavailable` — story has expired and we never received its webhook
          payload (for example, the account connected after the story expired)

        Field semantics follow Meta's API. Counts below 5 may be returned as 0
        due to Meta's privacy floor on small audiences. The `navigation` field
        is the sum of `tapsForward + tapsBack + exits + swipesForward`.
      parameters:
        - name: accountId
          in: path
          required: true
          description: The Instagram account ID
          schema: { type: string }
        - name: storyId
          in: path
          required: true
          description: The Instagram media ID of the story.
          schema: { type: string }
      responses:
        '200':
          description: Story insights
          content:
            application/json:
              schema:
                type: object
                required: [data]
                properties:
                  data:
                    type: object
                    required: [source, metrics]
                    properties:
                      source:
                        type: string
                        enum: [live, cached, unavailable]
                      metrics:
                        type: object
                        required:
                          [views, reach, replies, shares, navigation, tapsForward,
                           tapsBack, exits, swipesForward, profileVisits, follows,
                           reposts, totalInteractions]
                        properties:
                          views: { type: integer, description: "Total story plays. Replaces deprecated 'impressions' for media created after 2024-07-02." }
                          reach: { type: integer, description: Unique accounts that saw the story. }
                          replies: { type: integer, description: DMs sent in reply to the story. }
                          shares: { type: integer }
                          navigation: { type: integer, description: Total nav actions (tapsForward + tapsBack + exits + swipesForward). }
                          tapsForward: { type: integer, description: Tapped right to next slide of SAME story. }
                          tapsBack: { type: integer, description: Tapped left to previous slide. }
                          exits: { type: integer, description: Closed Stories interface entirely. }
                          swipesForward: { type: integer, description: "Swiped left to next account's story." }
                          profileVisits: { type: integer }
                          follows: { type: integer }
                          reposts: { type: integer }
                          totalInteractions: { type: integer }
        '400': { description: Invalid request. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Instagram account not found. }

  /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, "null"]
              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 Google Business Profile locations the connected account can access,
        plus the currently selected location. The list is bounded (see hasMore); for
        accounts that own many locations, use the search or filter query params to find
        a specific one instead of loading them all.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
        - name: search
          in: query
          required: false
          schema: { type: string }
          description: Free-text search on the business name, applied server-side by Google. Use for accounts with many locations.
        - name: filter
          in: query
          required: false
          schema: { type: string }
          description: Raw Google Business Information API filter expression (advanced; takes precedence over search), e.g. storeCode="LH279411".
      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 }
                        storeCode: { type: string }
                  hasMore:
                    type: boolean
                    description: True when more locations exist than were returned (use search to narrow down).
                  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 }
                googleAccountId:
                  type: string
                  description: >
                    Optional but recommended. The Google Business Account resource name
                    ("accounts/123") that owns the new location (from GET gmb-locations).
                    When provided, the location is resolved directly instead of by
                    enumerating the account, which is required for accounts with many
                    locations. Named `googleAccountId` to disambiguate from the path
                    `accountId` (the Zernio account). The legacy field name `accountId`
                    is still accepted for backwards compatibility.
            example:
              selectedLocationId: "12345678901234567890"
              googleAccountId: "accounts/123456789"
      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, "null"], description: Custom webhook display name (null = default "Zernio") }
                      webhookAvatarUrl: { type: [string, "null"], 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, "null"] }
                      webhookAvatarUrl: { type: [string, "null"] }
        '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/discord/dms:
    post:
      operationId: sendDiscordDirectMessage
      tags: [Discord]
      summary: Send a Discord Direct Message
      description: |
        Send a 1:1 Direct Message from the bot to a Discord user (by snowflake ID).
        Supports the same payload shape as channel posts — content, embeds, media
        attachments, and TTS.

        Constraints (Discord platform limits):
          - The bot can only DM users it shares at least one guild with.
          - If the recipient has DMs disabled for non-friends, Discord returns 403
            (surfaces as a 502 platform error).
          - `content` capped at 2,000 chars.
          - At least one of `content`, `embeds`, or `attachments` is required.
          - The recipient must be identified by Discord snowflake ID (not username).

        This is a dedicated endpoint rather than a `POST /v1/posts` variant because
        DMs are 1:1 operational messages (onboarding, billing reminders, support
        pings) with a different lifecycle than scheduled channel posts. DMs are
        not persisted to `Post` / `ExternalPost` and are always sent immediately.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, userId]
              properties:
                accountId:
                  type: string
                  description: SocialAccount _id of the connected Discord account the bot speaks as. Caller must own the account (directly or via team membership).
                  example: "65a1b2c3d4e5f60718293a4b"
                userId:
                  type: string
                  description: Discord snowflake ID of the recipient (15-21 digits).
                  example: "1234567890123456789"
                content:
                  type: string
                  description: Message text, up to 2,000 characters.
                  maxLength: 2000
                embeds:
                  type: array
                  description: Up to 10 Discord embeds. Same shape as channel-post embeds (title, description, color, fields, etc.). See DiscordPlatformData.embeds for the embed object schema.
                  maxItems: 10
                  items:
                    type: object
                attachments:
                  type: array
                  description: "Up to 10 media attachments. Each is `{ type: image|video|gif|document, url, filename?, mimeType?, size? }`."
                  maxItems: 10
                  items:
                    type: object
                    required: [type, url]
                    properties:
                      type:
                        type: string
                        enum: [image, video, gif, document]
                      url:
                        type: string
                        format: uri
                      filename: { type: string }
                      mimeType: { type: string }
                      size: { type: integer }
                tts:
                  type: boolean
                  description: Send as text-to-speech message.
            example:
              accountId: "65a1b2c3d4e5f60718293a4b"
              userId: "1234567890123456789"
              content: "Welcome to Acme! Reply STOP to opt out."
      responses:
        '200':
          description: DM sent successfully.
          content:
            application/json:
              schema:
                type: object
                properties:
                  messageId: { type: string, description: Discord message snowflake ID }
                  channelId: { type: string, description: DM channel snowflake (Discord auto-creates one per recipient pair) }
                  url: { type: string, description: "Direct link to the message — uses Discord's @me path for DMs" }
                  timestamp: { type: string, format: date-time }
                  recipient:
                    type: object
                    properties:
                      userId: { type: string }
                      platform: { type: string, example: discord }
                  account:
                    type: object
                    properties:
                      id: { type: string }
                      username: { type: string }
                      displayName: { type: string }
              example:
                messageId: "1357924680135792468"
                channelId: "1357924680135792467"
                url: "https://discord.com/channels/@me/1357924680135792467/1357924680135792468"
                timestamp: "2026-06-02T12:34:56.789Z"
                recipient: { userId: "1234567890123456789", platform: "discord" }
                account: { id: "65a1b2c3d4e5f60718293a4b", username: "announcements", displayName: "My Server - #announcements" }
        '400': { description: "Validation error (missing required fields, content > 2000 chars, malformed snowflake, or all of content/embeds/attachments missing)." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Discord account not found or not accessible to this user. }
        '502': { description: "Discord rejected the message (most commonly: bot doesn't share a guild with the recipient, OR the recipient has DMs disabled). Error body contains Discord's response." }

  /v1/discord/guilds/{guildId}/roles:
    get:
      operationId: listDiscordGuildRoles
      tags: [Discord]
      summary: List Discord guild roles
      description: |
        Returns all roles in a Discord guild. Useful for building role-mention
        pickers, role-permission UIs, or finding the role ID before calling
        the role-assign endpoint.

        Roles are returned unordered — sort client-side by `position` if you
        need Discord's UI ordering.

        Caller must pass `accountId` of a Discord SocialAccount bound to this
        guild (route verifies team access + guild match).
      parameters:
        - { name: guildId, in: path, required: true, schema: { type: string }, description: Discord guild snowflake ID }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this guild }
      responses:
        '200':
          description: List of guild roles.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: Role snowflake ID }
                        name: { type: string }
                        color: { type: integer, description: Decimal color (0 = no color). Convert to hex via .toString(16). }
                        position: { type: integer, description: Position in role hierarchy (higher = more authority) }
                        permissions: { type: string, description: Permissions bitfield as a stringified integer }
                        managed: { type: boolean, description: True for integration-managed roles (bot roles) }
                        mentionable: { type: boolean }
                        hoist: { type: boolean, description: True if role is displayed separately in member list }
        '400': { description: Invalid accountId or guildId format. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "Discord account not found, not accessible, or not bound to this guild." }
        '502': { description: Discord rejected the request (bot lacks View Channels permission in the guild). }

  /v1/discord/guilds/{guildId}/members:
    get:
      operationId: listDiscordGuildMembers
      tags: [Discord]
      summary: List Discord guild members
      description: |
        Cursor-paginated list of guild members. Returns Discord's raw member
        objects so callers can build community-ops automation (e.g. "add role
        to all members joined in the last 7 days") on the actual platform shape.

        **Important:** this endpoint requires the privileged "Server Members
        Intent" enabled on the Discord app (Developer Portal → Bot tab → toggle
        "Server Members Intent" ON, then Save). Without it, Discord returns an
        empty array with no error. Verify the intent is enabled before relying
        on this endpoint.

        Pagination: pass `after` = the last `user.id` from the previous page.
        Omit on the first call. Response includes a `nextCursor` and `hasMore`
        flag so callers don't need to know Discord's pagination shape.
      parameters:
        - { name: guildId, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }, description: Page size (1-1000). }
        - { name: after, in: query, required: false, schema: { type: string }, description: Snowflake of the last member from the previous page. }
      responses:
        '200':
          description: List of guild members.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        user:
                          type: object
                          properties:
                            id: { type: string, description: User snowflake }
                            username: { type: string }
                            discriminator: { type: string }
                            avatar: { type: [string, "null"] }
                            global_name: { type: [string, "null"], description: User's display name (post-2023 Discord rebrand) }
                        nick: { type: [string, "null"], description: Guild-specific nickname }
                        roles: { type: array, items: { type: string }, description: Snowflake IDs of roles assigned to this member }
                        joined_at: { type: string, format: date-time }
                        premium_since: { type: [string, "null"], format: date-time, description: When the user started boosting the server }
                  pagination:
                    type: object
                    properties:
                      nextCursor: { type: [string, "null"], description: Pass as `after` on the next call. Null when there are no more pages. }
                      hasMore: { type: boolean }
        '400': { description: Invalid query params. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Discord account not found or not in this guild. }

  /v1/discord/guilds/{guildId}/members/{userId}/roles/{roleId}:
    put:
      operationId: addDiscordMemberRole
      tags: [Discord]
      summary: Assign a role to a guild member
      description: |
        Assign one role to one member. Idempotent on Discord's side — re-running
        on a member who already has the role is a 204 no-op.

        Path shape mirrors Discord's own API (`PUT /guilds/{guild}/members/{user}/roles/{role}`)
        for zero-translation mental mapping.

        Bot needs MANAGE_ROLES permission in the guild AND its highest role
        must be above the target role (Discord hierarchy rule). The
        `@everyone` role (where roleId == guildId) cannot be assigned.
      parameters:
        - { name: guildId, in: path, required: true, schema: { type: string } }
        - { name: userId, in: path, required: true, schema: { type: string }, description: Discord user snowflake to assign the role to. }
        - { name: roleId, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Role assigned (or already present — idempotent).
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  operation: { type: string, enum: [role_assigned] }
                  guildId: { type: string }
                  userId: { type: string }
                  roleId: { type: string }
        '400': { description: "Validation error (malformed snowflake) or @everyone manipulation attempt." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Discord account not found or not in this guild. }
        '502': { description: "Discord rejected the request: bot lacks MANAGE_ROLES, or target role is above the bot's highest role." }

    delete:
      operationId: removeDiscordMemberRole
      tags: [Discord]
      summary: Remove a role from a guild member
      description: |
        Remove one role from one member. Idempotent — removing a role the
        member doesn't have returns 204 no-op.

        Same permission + hierarchy constraints as the PUT counterpart.
      parameters:
        - { name: guildId, in: path, required: true, schema: { type: string } }
        - { name: userId, in: path, required: true, schema: { type: string } }
        - { name: roleId, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Role removed (or was already absent — idempotent).
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  operation: { type: string, enum: [role_removed] }
                  guildId: { type: string }
                  userId: { type: string }
                  roleId: { type: string }
        '400': { description: Validation error. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Discord account not found or not in this guild. }
        '502': { description: Discord rejected the request (permission or hierarchy issue). }

  /v1/discord/channels/{channelId}/pins:
    get:
      operationId: listDiscordPinnedMessages
      tags: [Discord]
      summary: List pinned messages
      description: |
        Returns the channel's pinned messages, sorted most-recently-pinned
        first. Discord caps a channel at 50 pinned messages and returns the
        full list unpaginated.

        Bot needs READ_MESSAGE_HISTORY in the channel (granted by default
        BOT_PERMISSIONS).
      parameters:
        - { name: channelId, in: path, required: true, schema: { type: string }, description: Discord channel snowflake. }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of any Discord account in the same guild. }
      responses:
        '200':
          description: Pinned messages.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      description: Raw Discord Message object — see DiscordPlatformData for embed/attachment shapes.
                      properties:
                        id: { type: string }
                        channel_id: { type: string }
                        content: { type: string }
                        timestamp: { type: string, format: date-time }
                        author: { type: object }
                        attachments: { type: array, items: { type: object } }
                        embeds: { type: array, items: { type: object } }
        '400': { description: Invalid channelId or accountId format. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Discord account not found or not accessible. }
        '502': { description: Bot lacks access to the channel. }

  /v1/discord/channels/{channelId}/pins/{messageId}:
    put:
      operationId: pinDiscordMessage
      tags: [Discord]
      summary: Pin a Discord message
      description: |
        Pin a specific message in a channel. Path shape mirrors Discord's own
        API (`PUT /channels/{cid}/pins/{mid}`).

        Idempotent — re-pinning an already-pinned message is a 204 no-op.

        Constraints:
          - Bot needs MANAGE_MESSAGES in the channel.
          - 50-pin cap per channel — hitting it returns 400 (Discord-side).
            Caller should unpin one first.
      parameters:
        - { name: channelId, in: path, required: true, schema: { type: string } }
        - { name: messageId, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Message pinned (or was already pinned — idempotent).
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  operation: { type: string, enum: [message_pinned] }
                  channelId: { type: string }
                  messageId: { type: string }
        '400': { description: Validation error or pin cap (50) reached. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Discord account not found. }
        '502': { description: Bot lacks MANAGE_MESSAGES in the channel. }

    delete:
      operationId: unpinDiscordMessage
      tags: [Discord]
      summary: Unpin a Discord message
      description: |
        Unpin a message. Same MANAGE_MESSAGES permission requirement as pin.
        Idempotent — unpinning a non-pinned message is a 204 no-op.
      parameters:
        - { name: channelId, in: path, required: true, schema: { type: string } }
        - { name: messageId, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Message unpinned (or was not pinned — idempotent).
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  operation: { type: string, enum: [message_unpinned] }
                  channelId: { type: string }
                  messageId: { type: string }
        '400': { description: Validation error. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Discord account not found. }
        '502': { description: Bot lacks MANAGE_MESSAGES in the channel. }

  /v1/discord/guilds/{guildId}/events:
    get:
      operationId: listDiscordScheduledEvents
      tags: [Discord]
      summary: List Discord scheduled events
      description: |
        Return all scheduled events in the guild. Events are distinct from
        messages — they appear in the server's Events panel and Discord
        auto-notifies interested members ahead of start time.

        Pass `withUserCount=true` to include `user_count` (number of members
        who RSVP'd) on each event. Useful for surfacing engagement.
      parameters:
        - { name: guildId, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
        - { name: withUserCount, in: query, required: false, schema: { type: boolean }, description: Include user_count on each event. }
      responses:
        '200':
          description: List of scheduled events.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      $ref: '#/components/schemas/DiscordScheduledEvent'
        '400': { description: Invalid params. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Discord account not found or not in this guild. }
        '502': { description: Bot lacks access to the guild's events. }

    post:
      operationId: createDiscordScheduledEvent
      tags: [Discord]
      summary: Create a Discord scheduled event
      description: |
        Create a guild scheduled event. Three event types, selected via the
        discriminator on `entity.type`:

          - `external` — off-platform (Zoom, in-person, livestream). Requires
            both `location` and `endsAt`. Most common type for scheduler
            integrations.
          - `voice` — hosted in a Discord voice channel. Requires `channelId`.
          - `stage` — hosted in a Discord stage channel. Requires `channelId`.

        Bot needs MANAGE_EVENTS in the guild. Existing installs (pre-events
        PR) need a re-invite OR a server admin manually granting the
        permission — see route header for details.
      parameters:
        - { name: guildId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, name, startsAt, entity]
              properties:
                accountId: { type: string }
                name: { type: string, minLength: 1, maxLength: 100 }
                description: { type: string, maxLength: 1000 }
                startsAt: { type: string, format: date-time, description: ISO 8601 start time. Must be in the future. }
                entity:
                  oneOf:
                    - type: object
                      required: [type, location, endsAt]
                      properties:
                        type: { type: string, enum: [external] }
                        location: { type: string, minLength: 1, maxLength: 100, description: "Where the event takes place (e.g. \"Zoom link\", \"123 Main St\")" }
                        endsAt: { type: string, format: date-time }
                    - type: object
                      required: [type, channelId]
                      properties:
                        type: { type: string, enum: [voice] }
                        channelId: { type: string, description: Voice channel snowflake. }
                        endsAt: { type: string, format: date-time }
                    - type: object
                      required: [type, channelId]
                      properties:
                        type: { type: string, enum: [stage] }
                        channelId: { type: string, description: Stage channel snowflake. }
                        endsAt: { type: string, format: date-time }
                imageDataUri:
                  type: string
                  pattern: "^data:image/(png|jpeg|gif);base64,"
                  description: Optional cover image as a base64 data URI.
            examples:
              external:
                value:
                  accountId: "65a1b2c3d4e5f60718293a4b"
                  name: "Weekly AMA"
                  description: "Bring your questions about the roadmap."
                  startsAt: "2026-06-15T18:00:00Z"
                  entity: { type: external, location: "https://zoom.us/j/123", endsAt: "2026-06-15T19:00:00Z" }
              voice:
                value:
                  accountId: "65a1b2c3d4e5f60718293a4b"
                  name: "Game Night"
                  startsAt: "2026-06-20T20:00:00Z"
                  entity: { type: voice, channelId: "1234567890123456789" }
      responses:
        '200':
          description: Event created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/DiscordScheduledEvent' }
        '400': { description: "Validation error (missing required fields for the chosen entity type, malformed snowflake, past startsAt, etc.)." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Discord account not found. }
        '502': { description: Bot lacks MANAGE_EVENTS in the guild. }

  /v1/discord/guilds/{guildId}/events/{eventId}:
    get:
      operationId: getDiscordScheduledEvent
      tags: [Discord]
      summary: Get a Discord scheduled event
      parameters:
        - { name: guildId, in: path, required: true, schema: { type: string } }
        - { name: eventId, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Event.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/DiscordScheduledEvent' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Event or Discord account not found. }

    patch:
      operationId: updateDiscordScheduledEvent
      tags: [Discord]
      summary: Update a Discord scheduled event
      description: |
        Patch any subset of fields. Passing `status: 'cancelled'` is how you
        cancel an event — Discord doesn't have a dedicated cancel endpoint,
        it's a status transition.

        Most status transitions Discord enforces (you can't go SCHEDULED →
        COMPLETED directly). The common consumer case is SCHEDULED → CANCELED.
      parameters:
        - { name: guildId, in: path, required: true, schema: { type: string } }
        - { name: eventId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId: { type: string }
                name: { type: string, minLength: 1, maxLength: 100 }
                description: { type: string, maxLength: 1000 }
                startsAt: { type: string, format: date-time }
                endsAt: { type: string, format: date-time }
                location: { type: string, minLength: 1, maxLength: 100, description: For external events. }
                status: { type: string, enum: [scheduled, active, completed, cancelled], description: "Status transition. Most common: 'cancelled' to cancel an event." }
                imageDataUri: { type: string, pattern: "^data:image/(png|jpeg|gif);base64," }
            example:
              accountId: "65a1b2c3d4e5f60718293a4b"
              status: "cancelled"
      responses:
        '200':
          description: Event updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/DiscordScheduledEvent' }
        '400': { description: "Validation error, OR no updatable fields beyond accountId provided." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Event or Discord account not found. }
        '502': { description: "Discord rejected the update (invalid status transition, bot permissions, etc.)." }

    delete:
      operationId: deleteDiscordScheduledEvent
      tags: [Discord]
      summary: Delete a Discord scheduled event
      description: |
        Hard-delete an event. Use PATCH with `status: 'cancelled'` instead
        if you want the event preserved in the guild's history.
      parameters:
        - { name: guildId, in: path, required: true, schema: { type: string } }
        - { name: eventId, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Event deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  deleted: { type: string, description: The deleted event's snowflake. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Event or Discord account not found. }
        '502': { description: Bot lacks MANAGE_EVENTS in the guild. }

  /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:
                  - $ref: '#/components/schemas/QueueSlotsResponse'
                  - 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:
                $ref: '#/components/schemas/QueueUpdateResponse'
              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:
                $ref: '#/components/schemas/QueueDeleteResponse'
              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:
                $ref: '#/components/schemas/QueuePreviewResponse'
              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:
                $ref: '#/components/schemas/QueueNextSlotResponse'
              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, post.platform.published, post.platform.failed, post.tiktok.url_resolved, post.external.created, post.external.updated, post.external.deleted, account.connected, account.disconnected, account.ads.initial_sync_completed, message.received, message.sent, message.edited, message.deleted, message.delivered, message.read, message.failed, reaction.received, comment.received, review.new, review.updated, ad.status_changed, whatsapp.template.status_updated, whatsapp.number.activated, whatsapp.number.declined, whatsapp.number.action_required, whatsapp.number.verification_required, whatsapp.number.suspended, whatsapp.number.reactivated, whatsapp.number.released, whatsapp.number.kyc_submitted]
                  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", "message.sent", "message.edited", "message.deleted", "message.delivered", "message.read", "message.failed", "comment.received", "review.new", "review.updated", "ad.status_changed"]
                  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, post.platform.published, post.platform.failed, post.tiktok.url_resolved, post.external.created, post.external.updated, post.external.deleted, account.connected, account.disconnected, account.ads.initial_sync_completed, message.received, message.sent, message.edited, message.deleted, message.delivered, message.read, message.failed, reaction.received, comment.received, review.new, review.updated, ad.status_changed, whatsapp.template.status_updated, whatsapp.number.activated, whatsapp.number.declined, whatsapp.number.action_required, whatsapp.number.verification_required, whatsapp.number.suspended, whatsapp.number.reactivated, whatsapp.number.released, whatsapp.number.kyc_submitted]
                  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/logs:
    get:
      operationId: getWebhookLogs
      tags: [Webhooks]
      summary: List webhook delivery logs
      description: |
        Retrieve recorded webhook delivery attempts for the authenticated user, most recent first.
        Logs are retained for 30 days. Supports filtering by status, event type, webhook ID, and event ID,
        plus offset-based pagination.
      security:
        - bearerAuth: []
      parameters:
        - name: limit
          in: query
          description: Maximum number of logs to return
          schema:
            type: integer
            minimum: 1
            maximum: 100
            default: 50
        - name: skip
          in: query
          description: Number of logs to skip (offset-based pagination)
          schema:
            type: integer
            minimum: 0
            maximum: 10000
            default: 0
        - name: status
          in: query
          description: Filter by delivery outcome
          schema:
            type: string
            enum: [success, failed]
        - name: event
          in: query
          description: Filter by event type (e.g. post.published)
          schema:
            type: string
            minLength: 1
            maxLength: 100
        - name: webhookId
          in: query
          description: Filter by webhook configuration ID
          schema:
            type: string
            minLength: 1
            maxLength: 100
        - name: eventId
          in: query
          description: Filter by stable webhook event ID
          schema:
            type: string
            minLength: 1
            maxLength: 200
      responses:
        '200':
          description: Webhook logs retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  logs:
                    type: array
                    items:
                      $ref: '#/components/schemas/WebhookLog'
                  pagination:
                    type: object
                    properties:
                      total:
                        type: integer
                        description: Total number of matching logs
                      limit:
                        type: integer
                        description: Maximum number of logs returned per page
                      skip:
                        type: integer
                        description: Number of logs skipped
                      pages:
                        type: integer
                        description: Total number of pages
                      hasMore:
                        type: boolean
                        description: Whether more logs are available beyond this page
        '400': { description: Invalid query parameter }
        '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. Use `all` for the unified view across every category,
            or `api_request` for your API request logs (method, path, status, latency).
          schema:
            type: string
            enum: [all, publishing, connections, webhooks, messaging, workflow_event, api_request]
            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
        - name: account_id
          in: query
          description: Filter by connected account ID
          schema:
            type: string
        - name: event
          in: query
          description: Filter webhook logs by event (e.g. post.published, message.received)
          schema:
            type: string
        - name: request_id
          in: query
          description: Correlation ID — returns every log spawned by a single API request
          schema:
            type: string
        - name: from
          in: query
          description: Precise start instant (ISO 8601); narrows within the day range
          schema:
            type: string
            format: date-time
        - name: to
          in: query
          description: Precise end instant (ISO 8601)
          schema:
            type: string
            format: date-time
        - name: status_code
          in: query
          description: Filter by exact HTTP status code (api_request logs)
          schema:
            type: integer
        - name: api_key_id
          in: query
          description: Filter by the API key that made the request (api_request logs)
          schema:
            type: string
        - name: include_read_receipts
          in: query
          description: Include message.read / message.delivered events (hidden by default for messaging logs)
          schema:
            type: boolean
            default: false
      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
                        request_id:
                          type: string
                          description: Correlation ID linking every log from one API request (api_request logs)
                        api_key_id:
                          type: string
                          description: The API key that made the request (api_request logs)
                        method:
                          type: string
                          description: HTTP method (api_request logs)
                        path:
                          type: string
                          description: Request path (api_request logs)
                        ip_address:
                          type: string
                          description: Client IP address (api_request logs)
                        user_agent:
                          type: string
                          description: Client user-agent (api_request logs)
                  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, "null"] }
                        participantVerifiedType:
                          type: [string, "null"]
                          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, "null"], description: Number of unread messages }
                        url:
                          type: [string, "null"]
                          description: Direct link to open the conversation on the platform (if available)
                        instagramProfile:
                          type: [object, "null"]
                          description: Instagram profile data for the participant. Only present for Instagram conversations.
                          properties:
                            isFollower:
                              type: [boolean, "null"]
                              description: Whether the participant follows your Instagram business account
                            isFollowing:
                              type: [boolean, "null"]
                              description: Whether your Instagram business account follows the participant
                            followerCount:
                              type: [integer, "null"]
                              description: The participant's follower count on Instagram
                            isVerified:
                              type: [boolean, "null"]
                              description: Whether the participant is a verified Instagram user
                            fetchedAt:
                              type: [string, "null"]
                              format: date-time
                              description: When this profile data was last fetched from Instagram
                  pagination:
                    type: object
                    properties:
                      hasMore: { type: boolean }
                      nextCursor: { type: [string, "null"] }
                  meta:
                    type: object
                    properties:
                      accountsQueried: { type: integer }
                      accountsFailed: { type: integer }
                      failedAccounts:
                        type: array
                        items:
                          type: object
                          properties:
                            accountId: { type: string }
                            accountUsername: { type: [string, "null"] }
                            platform: { type: string }
                            error: { type: string }
                            code: { type: [string, "null"], description: Error code if available }
                            retryAfter: { type: [integer, "null"], 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.

        Supported platforms: X/Twitter, Bluesky, Reddit, and WhatsApp. Other platforms return PLATFORM_NOT_SUPPORTED.

        WhatsApp: this is the endpoint for sending an approved template message to a phone number. Provide templateName, templateLanguage, and templateParams (body variable values), with the recipient phone in participantId. A template is required because WhatsApp does not permit freeform messages to open a conversation; a missing template returns TEMPLATE_REQUIRED. Templates with media headers (image, video, document) are handled automatically: Zernio reads the approved template definition and fills the header at send time. Calling this for a number you already have a thread with simply sends the template into that thread, which also makes it the way to re-engage a contact after the 24-hour customer-service window has closed. Once the recipient replies (opening the 24h window), send freeform messages with the send-message endpoint (POST /v1/inbox/conversations/{conversationId}/messages). Template fields are accepted on the JSON body only, not on multipart requests.

        DM eligibility (X/Twitter): 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: Recipient identifier. For X this is the numeric user ID; for WhatsApp, the recipient phone number in international format (digits, country code included). Provide either this or participantUsername.
                participantUsername:
                  type: string
                  description: Recipient handle/username — an X or Bluesky handle (with or without @) or a Reddit username (with or without u/). Resolved via lookup. Provide either this or participantId.
                message:
                  type: string
                  description: Text content of the message. At least one of message, attachment, or (for WhatsApp) templateName is required.
                skipDmCheck:
                  type: boolean
                  default: false
                  description: X/Twitter only. Skip the receives_your_dm eligibility check before sending. Use if you have already verified the recipient accepts DMs.
                templateName:
                  type: string
                  description: WhatsApp only. Name of the approved template to start the conversation with (required for WhatsApp).
                templateLanguage:
                  type: string
                  description: WhatsApp only. Template language code (e.g. en_US).
                templateParams:
                  type: array
                  items: { type: string }
                  description: WhatsApp only. Body variable values, in order. Works with positional placeholders ({{1}}, {{2}}, ...) and with named placeholders ({{name}}, {{company}} - how Meta Business Manager creates templates), where values fill the named slots in order of appearance.
          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, "null"]
                        description: Display name of the recipient
                      participantUsername:
                        type: [string, "null"]
                        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/search:
    get:
      operationId: searchInboxConversations
      summary: Search conversations
      description: |
        Search message text across your conversations and get back the conversations that contain the query, each with up to 3 most-recent matching messages. Useful for finding threads about a topic, or (with direction=outgoing) collecting examples of how you write to customers, for example to teach an AI agent your tone of voice.

        Only platforms whose messages are stored by Zernio are searchable: WhatsApp, SMS, Telegram, Facebook and Instagram. Twitter/X, Bluesky and Reddit conversations are fetched live from the platforms and cannot be searched; those accounts are listed in meta.accountsSkipped.

        Matching is word-based: case-insensitive and accent-insensitive, exact tokens only (no substrings, no stemming). Quote a phrase to match it exactly.
      tags: [Messages]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: query
          in: query
          required: true
          schema: { type: string, minLength: 2, maxLength: 200 }
          description: Text to search for in message content
        - name: direction
          in: query
          schema: { type: string, enum: [incoming, outgoing] }
          description: Only match messages sent to you (incoming) or by you (outgoing)
        - name: profileId
          in: query
          schema: { type: string }
          description: Filter by profile ID
        - name: platform
          in: query
          schema: { type: string, enum: [facebook, instagram, telegram, whatsapp, sms] }
          description: Filter by platform (searchable platforms only)
        - name: accountId
          in: query
          schema: { type: string }
          description: Filter by specific social account ID
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 50, default: 20 }
          description: Maximum number of conversations to return
        - name: cursor
          in: query
          schema: { type: string }
          description: Pagination cursor for next page
      responses:
        '200':
          description: Conversations containing the query, most recent match first
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        conversation:
                          type: object
                          properties:
                            id: { type: string, description: "Conversation ID, usable with the conversation messages endpoints" }
                            platform: { type: string }
                            accountId: { type: string }
                            participantName: { type: [string, "null"] }
                            participantUsername: { type: [string, "null"] }
                            participantPicture: { type: [string, "null"] }
                            status: { type: string, enum: [active, archived] }
                            lastMessageAt: { type: [string, "null"], format: date-time }
                        matchCount:
                          type: integer
                          description: Total number of matching messages in this conversation
                        matches:
                          type: array
                          description: Up to 3 most-recent matching messages
                          items:
                            type: object
                            properties:
                              id: { type: string }
                              text: { type: [string, "null"] }
                              direction: { type: string, enum: [incoming, outgoing] }
                              timestamp: { type: string, format: date-time }
                  pagination:
                    type: object
                    properties:
                      hasMore: { type: boolean }
                      nextCursor: { type: [string, "null"] }
                  meta:
                    type: object
                    properties:
                      accountsQueried: { type: integer }
                      accountsFailed: { type: integer }
                      failedAccounts:
                        type: array
                        items:
                          type: object
                          properties:
                            accountId: { type: string }
                            accountUsername: { type: [string, "null"] }
                            platform: { type: string }
                            error: { type: string }
                      lastUpdated: { type: string, format: date-time }
                      accountsSkipped:
                        type: array
                        description: Connected messaging accounts that cannot be searched (live-fetched platforms)
                        items:
                          type: object
                          properties:
                            accountId: { type: string }
                            platform: { type: string }
        '400':
          description: Invalid query or unsupported platform
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
  /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, "null"]
                        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, "null"]
                        description: Instagram profile data for the participant. Only present for Instagram conversations.
                        properties:
                          isFollower:
                            type: [boolean, "null"]
                            description: Whether the participant follows your Instagram business account
                          isFollowing:
                            type: [boolean, "null"]
                            description: Whether your Instagram business account follows the participant
                          followerCount:
                            type: [integer, "null"]
                            description: The participant's follower count on Instagram
                          isVerified:
                            type: [boolean, "null"]
                            description: Whether the participant is a verified Instagram user
                          fetchedAt:
                            type: [string, "null"]
                            format: date-time
                            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`.
        Twitter, Instagram, Telegram, WhatsApp and Reddit honor the requested
        order from the local message store. For 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.

        This endpoint is read-only and does NOT mark messages as read or send
        read receipts. To mark a conversation read (and send WhatsApp blue ticks
        on eligible accounts), call `POST /v1/inbox/conversations/{conversationId}/read`.
      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).
            Twitter, Instagram, Telegram, WhatsApp and Reddit honor this order
            across cursor pages. For 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, "null"]
                        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 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, "null"] }
                        senderVerifiedType:
                          type: [string, "null"]
                          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, "null"] }
                              previewUrl: { type: [string, "null"] }
                        subject: { type: [string, "null"], description: Reddit message subject }
                        storyReply: { type: [boolean, "null"], description: Instagram story reply }
                        isStoryMention: { type: [boolean, "null"], 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, "null"]
                          format: date-time
                          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, "null"] }
                              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, "null"]
                          format: date-time
                        deliveryStatus:
                          type: [string, "null"]
                          enum: [sent, delivered, read, failed, deleted]
                          description: Lifecycle status for outgoing messages. Not all platforms emit every state (see webhook support matrix).
                        deliveredAt:
                          type: [string, "null"]
                          format: date-time
                        readAt:
                          type: [string, "null"]
                          format: date-time
                        sentAt:
                          type: [string, "null"]
                          format: date-time
                          description: Original send time for outgoing messages (used for Messenger watermark queries).
                        deliveryError:
                          type: [object, "null"]
                          description: Populated when deliveryStatus === "failed".
                          properties:
                            code: { type: integer }
                            title: { type: string }
                            message: { type: string }
                        reactions:
                          type: array
                          description: Emoji reactions on this message (WhatsApp / Telegram). At most one per party in a 1:1 thread.
                          items:
                            type: object
                            properties:
                              emoji: { type: string }
                              fromMe: { type: boolean, description: "true if the connected account reacted, false if the contact did." }
                              reactedAt: { type: string, format: date-time }
                        metadata:
                          type: object
                          description: |
                            Platform-specific extras. Free-form, but commonly includes:
                            `quotedMessageId` (platformMessageId this message replies to),
                            `waInteractive` (a compact descriptor of WhatsApp interactive
                            content sent: buttons / list / cta_url / flow / location_request),
                            and for inbound interactive taps `interactiveType` / `interactiveId`.
                          additionalProperties: true
                  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 template messages: to send an approved template into this
        conversation (required when the 24-hour customer-service window is
        closed), use the `template` field with a single element carrying the
        template reference: `{ "elements": [{ "name": ..., "language": ..., "components": [...] }] }`.
        See the `template` field below for the exact shape. To send a template
        to a phone number you have no conversation with yet, use the
        create-conversation endpoint (POST /v1/inbox/conversations) instead.

        WhatsApp rich interactive messages (list, CTA URL, Flow, location request)
        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."
                attachmentName:
                  type: string
                  description: "WhatsApp only. Display name for a document sent via attachmentUrl with attachmentType: file (e.g. \"Report.pdf\"). Maps to the recipient's file name; without it WhatsApp derives the name from the URL and shows \"Untitled\". Ignored for image/video/audio and for binary uploads (which use the uploaded file's name)."
                voiceNote:
                  type: boolean
                  description: |
                    WhatsApp only. When `true` on an audio attachment, the message is sent
                    as a voice message (PTT) — the recipient sees the waveform + voice-note
                    UI instead of a basic audio attachment. The audio file MUST be `.ogg`
                    encoded with the OPUS codec (mono) per Meta's voice-message contract;
                    other formats are rejected by WhatsApp. Ignored for non-audio attachments.
                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: |
                    Platform-dependent template payload. Ignored on Telegram.

                    Instagram / Facebook: a generic template (carousel). Set `type: generic`
                    and provide up to 10 `elements`, each with a `title` (required) and
                    optional `subtitle`, `imageUrl`, and `buttons`.

                    WhatsApp: sends an approved WhatsApp template message, the only message
                    type WhatsApp accepts when the 24-hour customer-service window is closed.
                    Provide exactly one element carrying the template reference:
                    `{ "elements": [{ "name": "order_update", "language": "en_US", "components": [...] }] }`
                    (`type` is ignored on WhatsApp). `components` is optional and is forwarded
                    unchanged as the `template.components` array of Meta's Cloud API send
                    payload; use it to fill body/header variables and button parameters, e.g.
                    `[{ "type": "body", "parameters": [{ "type": "text", "text": "John" }] }]`.
                    Templates with media headers (image, video, document) must include the
                    header component with its media link here at send time. To send a template
                    to a phone number with no existing conversation, or to have media headers
                    filled in automatically from the template definition, use the
                    create-conversation endpoint (POST /v1/inbox/conversations) instead.
                  properties:
                    type: { type: string, enum: [generic], description: Template type. Required for Instagram/Facebook generic templates; ignored on WhatsApp. }
                    elements:
                      type: array
                      maxItems: 10
                      items:
                        type: object
                        properties:
                          title: { type: string, maxLength: 80, description: Element title (max 80 chars). Required for Instagram/Facebook generic templates. }
                          subtitle: { type: string, description: Element subtitle (Instagram/Facebook only) }
                          imageUrl: { type: string, description: Element image URL (Instagram/Facebook only) }
                          buttons:
                            type: array
                            maxItems: 3
                            description: Element buttons (Instagram/Facebook only)
                            items:
                              type: object
                              properties:
                                type: { type: string, enum: [url, postback] }
                                title: { type: string, maxLength: 20 }
                                url: { type: string }
                                payload: { type: string }
                          name: { type: string, description: WhatsApp only. Name of the approved template to send. }
                          language: { type: string, description: WhatsApp only. Template language code (e.g. en_US). }
                          components:
                            type: array
                            description: WhatsApp only. Meta Cloud API send-shape components array, forwarded to Meta verbatim.
                            items:
                              type: object
                              additionalProperties: true
                interactive:
                  type: object
                  description: |
                    WhatsApp-only. Rich interactive payload for list messages, CTA URL
                    buttons, Flow prompts, and location requests. 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`, `flow`, `location_request_message`, or
                    `voice_call` messages.

                    For `voice_call`, the message renders WhatsApp's native call
                    button; tapping it starts a voice call to your business number.
                    Requires WhatsApp Business Calling to be enabled on the sending
                    number. The optional `parameters.payload` string is echoed back on
                    the `calls` webhook (as `cta_payload`) for attribution.

                    For `location_request_message`, `action` may be omitted (we default
                    it to `{ "name": "send_location" }`). WhatsApp renders a localized
                    "Send location" button; the user's reply arrives as a regular
                    location message in the conversation.

                    Tap events come back via the `message.received` webhook with
                    `metadata.interactiveType` set to `list_reply` or `nfm_reply`.
                  required: [type, body]
                  properties:
                    type:
                      type: string
                      enum: [list, cta_url, flow, location_request_message, voice_call]
                      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: Voice-call action. `type` on the parent must be `voice_call`. Renders WhatsApp's native call button (requires Calling enabled on the sending number).
                          required: [name]
                          properties:
                            name: { type: string, enum: [voice_call] }
                            parameters:
                              type: object
                              description: All optional.
                              properties:
                                display_text: { type: string, maxLength: 20, description: Button label. Defaults to "Call Now". }
                                ttl_minutes: { type: integer, minimum: 1, maximum: 43200, description: How long the button stays tappable. Defaults to 10080 (7 days). }
                                payload: { type: string, maxLength: 512, description: Arbitrary string echoed back as `cta_payload` on the `calls` webhook (connect/terminate) for attribution. }
                        - 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. }
                        - type: object
                          description: Location request action. `type` on the parent must be `location_request_message`. May be omitted entirely; it is defaulted.
                          required: [name]
                          properties:
                            name: { type: string, enum: [send_location] }
                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.
                location:
                  type: object
                  description: WhatsApp-only. Send a location pin.
                  required: [latitude, longitude]
                  properties:
                    latitude: { type: number, description: Latitude in decimal degrees. }
                    longitude: { type: number, description: Longitude in decimal degrees. }
                    name: { type: string, description: Optional location name. }
                    address: { type: string, description: Optional street address. }
                contacts:
                  type: array
                  description: WhatsApp-only. Send one or more contact cards.
                  items:
                    type: object
                    required: [name]
                    properties:
                      name:
                        type: object
                        required: [formatted_name]
                        properties:
                          formatted_name: { type: string, description: Full display name. }
                          first_name: { type: string }
                          last_name: { type: string }
                      phones:
                        type: array
                        items:
                          type: object
                          properties:
                            phone: { type: string }
                            type: { type: string, description: "e.g. CELL, WORK, HOME." }
                      emails:
                        type: array
                        items:
                          type: object
                          properties:
                            email: { type: string }
                            type: { type: string }
          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.
                voiceNote:
                  type: string
                  enum: ['true']
                  description: WhatsApp-only. Set to "true" when the audio attachment is an in-browser voice recording; the server transcodes it to a WhatsApp-native container (ogg/Opus). Omit for regular audio file uploads.
      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, "null"], description: Twitter conversation ID }
                      sentAt: { type: [string, "null"], format: date-time, description: Bluesky sent timestamp }
                      message: { type: [string, "null"], 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
        - WhatsApp: Shows "typing..." for up to 25 seconds. Requires a recent inbound message in the conversation (Meta references the inbound message id) and also marks that message as read as a side-effect.
        - 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}/read:
    post:
      operationId: markConversationRead
      summary: Mark a conversation as read
      description: |
        Marks all unread incoming messages in the conversation as read.

        For WhatsApp, this also sends read receipts (blue ticks) to the contact,
        EXCEPT on coexistence accounts (where the WhatsApp Business app on the
        customer's phone owns read state and we never override it).

        This is the explicit, human-driven counterpart to `GET .../messages`,
        which is side-effect-free and does NOT mark anything read. Call this when
        a user actually views the conversation.
      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: Conversation marked read
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  markedCount:
                    type: integer
                    description: Number of messages marked read by this call
        '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.

        For users with the Ads add-on (Metronome plans always qualify), the user's Meta ads
        (boosted/dark posts) are included too. There's one row per (ad, placement-with-comments):
        an ad that runs on both Facebook feed and Instagram feed produces up to two rows (the
        Page dark post and the IG media have separate comment threads), each flagged
        `isAd: true` with `adId` and `placement` (`id` is `{adId}:{placement}`). Use
        `?platform=metaads` to return *only* ad rows; passing `facebook`/`instagram` returns
        *organic* posts only (no ads); omitting `platform` returns both. Fetch a row's thread
        from GET /v1/ads/{adId}/comments?placement={placement}. Ad comment counts are read with
        the Marketing API token (Facebook side) or the connected Instagram account's token
        (Instagram side); a row whose count can't be read is omitted.
      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, metaads] }
          description: "Filter by platform. `metaads` is a synthetic value meaning the user's ads (boosted/dark posts) only; `facebook`/`instagram` return organic posts only."
        - 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, "null"] }
                        permalink: { type: [string, "null"] }
                        createdTime: { type: string, format: date-time }
                        commentCount: { type: integer }
                        likeCount: { type: integer }
                        cid: { type: [string, "null"], description: Bluesky content identifier }
                        subreddit: { type: [string, "null"], description: Reddit subreddit name }
                        isAd: { type: boolean, description: "True when this row is an ad (boosted/dark post). `platform` is then the placement (facebook = the Page dark post / instagram = the IG media), `id` is `{adId}:{placement}`, and the thread is at GET /v1/ads/{adId}/comments?placement={placement}." }
                        adId: { type: string, description: "Internal Zernio ad id — only on ad rows." }
                        placement: { type: string, enum: [facebook, instagram], description: "Which side of the ad this row's comments are on — only on ad rows." }
                  pagination:
                    type: object
                    properties:
                      hasMore: { type: boolean }
                      nextCursor: { type: [string, "null"] }
                  meta:
                    type: object
                    properties:
                      accountsQueried: { type: integer }
                      accountsFailed: { type: integer }
                      failedAccounts:
                        type: array
                        items:
                          type: object
                          properties:
                            accountId: { type: string }
                            accountUsername: { type: [string, "null"] }
                            platform: { type: string }
                            error: { type: string }
                            code: { type: [string, "null"], description: Error code if available }
                            retryAfter: { type: [integer, "null"], 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, "null"] }
                            isOwner: { type: boolean }
                            verifiedType:
                              type: [string, "null"]
                              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, "null"]
                          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, "null"], description: Bluesky like URI for unliking }
                        cid: { type: [string, "null"], description: Bluesky content identifier }
                        parentId: { type: [string, "null"], description: Parent comment ID for nested replies }
                        rootUri: { type: [string, "null"], description: Bluesky root post URI }
                        rootCid: { type: [string, "null"], description: Bluesky root post CID }
                  post:
                    type: [object, "null"]
                    description: |
                      (Reddit only) Metadata for the target post, returned alongside the comments in Reddit's
                      single round-trip. Lets integrators render a preview of the post the user is commenting on
                      without an additional request. Absent for non-Reddit platforms and when the upstream
                      response is missing the post listing (deleted post, malformed response).
                    properties:
                      id: { type: string, description: Reddit post base36 id (e.g. "1tjtj26") }
                      fullname: { type: string, description: Fullname with type prefix (e.g. "t3_1tjtj26") }
                      title: { type: string }
                      selftext: { type: string, description: Body text for self-posts (empty for link posts) }
                      author: { type: string, description: "Reddit username, without the u/ prefix" }
                      subreddit: { type: string, description: "Subreddit name, without the r/ prefix" }
                      permalink: { type: string, description: Absolute URL to the post on reddit.com }
                      url: { type: string, description: "For link posts, the external URL; for self-posts, the Reddit permalink" }
                      score: { type: integer, description: Net upvotes (upvotes minus downvotes) }
                      numComments: { type: integer }
                      createdUtc: { type: integer, description: Unix timestamp in seconds }
                      over18: { type: boolean }
                      stickied: { type: boolean }
                      flairText: { type: [string, "null"], description: Link flair text if any }
                      isGallery: { type: boolean, description: True if the post is a Reddit gallery (multiple images) }
                  pagination:
                    type: object
                    properties:
                      hasMore: { type: boolean }
                      cursor: { type: [string, "null"] }
                  meta:
                    type: object
                    properties:
                      platform: { type: string }
                      postId: { type: string }
                      accountId: { type: string }
                      subreddit: { type: [string, "null"], description: (Reddit only) Subreddit name }
                      lastUpdated: { type: string, format: date-time }
                      adComments:
                        type: [object, "null"]
                        description: "(Facebook/Instagram only) Present when this post has no organic comments but is a boosted post — the engagement lives on the ad. Use the ad-comments endpoint instead."
                        properties:
                          adId: { type: string, description: Internal Zernio ad ID }
                          adCommentsUrl: { type: string, description: "Path to fetch the ad's comments (GET /v1/ads/{adId}/comments)" }
        '400':
          description: |
            Invalid request, or the postId belongs to a Meta ad creative / ad ID rather than an organic post
            (code USE_AD_COMMENTS_ENDPOINT — response includes `adId` and `adCommentsUrl`).
        '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, "null"], 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. Optionally attach interactive elements:
        `quickReplies` (chips above the keyboard, max 13) or `buttons` (1-3 inline postback/url
        buttons rendered in the same bubble via Meta's button_template). Buttons are recommended
        for cold reach since chips do not render in the Instagram Message Requests folder.
        `quickReplies` and `buttons` are mutually exclusive.
      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
                quickReplies:
                  type: array
                  description: |
                    Optional quick-reply chips appended to the message. Visible only in the
                    Instagram and Messenger apps (not on web). Maximum 13 entries. Mutually
                    exclusive with `buttons`. Note: chips do NOT render in the Instagram
                    Message Requests folder where DMs from non-followers land — use `buttons`
                    instead for cold reach.
                  maxItems: 13
                  items:
                    type: object
                    required: [title, payload]
                    properties:
                      title:
                        type: string
                        maxLength: 20
                        description: Label shown on the chip. Truncated by Meta beyond 20 characters.
                      payload:
                        type: string
                        description: Opaque value returned in the inbound webhook when the user taps the chip.
                      imageUrl:
                        type: string
                        format: uri
                        description: Optional thumbnail shown next to the chip title.
                buttons:
                  type: array
                  description: |
                    Optional 1-3 inline buttons rendered as part of the same message bubble
                    via Meta's button_template. Visible in the Instagram Message Requests
                    folder (unlike quick replies). Mutually exclusive with `quickReplies`.
                  minItems: 1
                  maxItems: 3
                  items:
                    oneOf:
                      - type: object
                        required: [type, title, url]
                        properties:
                          type: { type: string, enum: [url] }
                          title: { type: string, maxLength: 20, description: Label shown on the button. }
                          url: { type: string, format: uri, description: URL opened when the button is tapped. }
                      - type: object
                        required: [type, title, payload]
                        properties:
                          type: { type: string, enum: [postback] }
                          title: { type: string, maxLength: 20, description: Label shown on the button. }
                          payload: { type: string, description: Opaque value returned in the inbound webhook when the user taps the button. }
                      - type: object
                        required: [type, title, phone]
                        properties:
                          type: { type: string, enum: [phone] }
                          title: { type: string, maxLength: 20, description: Label shown on the button. Facebook only. }
                          phone: { type: string, description: 'E.164 phone number dialed when tapped. Facebook only.' }
            examples:
              textOnly:
                summary: Plain text reply
                value:
                  accountId: "507f1f77bcf86cd799439011"
                  message: "Hi! Thanks for your comment. I wanted to reach out privately to help with your question."
              withQuickReplies:
                summary: Comment-to-DM with quick replies
                value:
                  accountId: "507f1f77bcf86cd799439011"
                  message: "Thanks for commenting! Want me to send the link?"
                  quickReplies:
                    - title: "Yes, send it"
                      payload: "SEND_LINK"
                    - title: "No thanks"
                      payload: "DECLINE"
              withButtons:
                summary: Comment-to-DM with inline buttons (recommended for cold reach)
                value:
                  accountId: "507f1f77bcf86cd799439011"
                  message: "Thanks for commenting! Tap below to grab the link."
                  buttons:
                    - type: "url"
                      title: "Get the link"
                      url: "https://tribenest.co/landing"
                    - type: "postback"
                      title: "Remind me later"
                      payload: "REMIND_LATER"
      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/mentions:
    get:
      operationId: listInboxMentions
      summary: List mentions
      description: |
        Returns mentions of your connected organization accounts, delivered via platform webhooks.
        Currently supports LinkedIn organization mentions.

        Requires Inbox addon.
      tags: [Mentions]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: accountId
          in: query
          schema: { type: string }
          description: Filter by social account ID
        - name: profileId
          in: query
          schema: { type: string }
          description: Filter by profile ID
        - name: sortOrder
          in: query
          schema: { type: string, enum: [asc, desc], default: desc }
          description: Sort order by publishedAt
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
        - name: cursor
          in: query
          schema: { type: string }
          description: Cursor for pagination (ID of the last item from the previous page)
      responses:
        '200':
          description: Paginated list of mentions
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          description: Mention document ID
                        platform:
                          type: string
                          enum: [linkedin]
                        accountId:
                          type: string
                        accountUsername:
                          type: string
                        content:
                          type: string
                          description: Text of the post that mentioned you
                        permalink:
                          type: [string, "null"]
                          description: URL to the source post on LinkedIn
                        authorUrn:
                          type: [string, "null"]
                          description: LinkedIn URN of the person who mentioned you
                        organizationalEntity:
                          type: string
                          description: URN of the organization that was mentioned
                        publishedAt:
                          type: string
                          format: date-time
                        createdAt:
                          type: string
                          format: date-time
                  pagination:
                    type: object
                    properties:
                      hasMore:
                        type: boolean
                      cursor:
                        type: [string, "null"]
                  meta:
                    type: object
                    properties:
                      total:
                        type: integer
                      sortOrder:
                        type: string
                        enum: [asc, desc]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Inbox addon required }

  /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, "null"] }
                            name: { type: string }
                            profileImage: { type: [string, "null"] }
                        rating: { type: integer }
                        text: { type: string }
                        created: { type: string, format: date-time }
                        hasReply: { type: boolean }
                        reply:
                          type: [object, "null"]
                          properties:
                            id: { type: string }
                            text: { type: string }
                            created: { type: string, format: date-time }
                        reviewUrl: { type: [string, "null"] }
                  pagination:
                    type: object
                    properties:
                      hasMore: { type: boolean }
                      nextCursor: { type: [string, "null"] }
                  meta:
                    type: object
                    properties:
                      accountsQueried: { type: integer }
                      accountsFailed: { type: integer }
                      failedAccounts:
                        type: array
                        items:
                          type: object
                          properties:
                            accountId: { type: string }
                            accountUsername: { type: [string, "null"] }
                            platform: { type: string }
                            error: { type: string }
                            code: { type: [string, "null"], description: Error code if available }
                            retryAfter: { type: [integer, "null"], description: Seconds to wait before retry (rate limits) }
                      lastUpdated: { type: string, format: date-time }
                  summary:
                    type: object
                    properties:
                      totalReviews: { type: integer }
                      averageRating: { type: [number, "null"] }
        '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' }

  # ──────────────────────────────────────────────────────────────────────────
  # WHATSAPP BUSINESS CALLING
  # ──────────────────────────────────────────────────────────────────────────

  /v1/whatsapp/calling:
    get:
      operationId: getWhatsAppCallingConfig
      tags: [WhatsApp Calling]
      summary: Get calling config for an account
      description: |
        Returns the local calling configuration snapshot for the connected
        WhatsApp account: whether calling is enabled, the forward-to
        destination URI, recording opt-in state, the phone number record id
        (use as `{id}` on the read-write calling sub-resource at
        /v1/phone-numbers/{id}/whatsapp/calling) and whether SIP digest
        credentials are stored (the encrypted password itself is never
        returned). Also carries account-level extras (billing eligibility,
        current-period spend) that the number-keyed GET does not.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
      responses:
        '200':
          description: Calling config
          content:
            application/json:
              schema:
                type: object
                properties:
                  phoneNumberDocId: { type: string, description: "Phone number record ID (use on /v1/phone-numbers/{id}/whatsapp/calling)" }
                  phoneNumber: { type: string }
                  callingEnabled: { type: boolean }
                  callDeepLink: { type: [string, "null"], description: "Public calling deep link (https://wa.me/call/<number>). Tapping it on a phone starts a WhatsApp voice call to this number. Embed it on websites, emails, or QR codes. Null while calling is disabled; not supported by WhatsApp desktop clients." }
                  forwardTo: { type: [string, "null"], description: "tel:+E164 / sip:... / wss://... destination" }
                  recordingEnabled: { type: boolean }
                  sipAuthUsername: { type: [string, "null"] }
                  sipAuthPasswordConfigured: { type: boolean, description: "True when a SIP digest password is stored. The plaintext is never returned." }
                  callIconCountries:
                    type: [array, "null"]
                    items: { type: string, minLength: 2, maxLength: 2 }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp phone number not found for this account }

  /v1/whatsapp/phone-numbers/{id}/calling:
    post:
      operationId: enableWhatsAppCallingLegacy
      deprecated: true
      tags: [WhatsApp Calling]
      summary: Enable calling on a number
      description: |
        Deprecated alias of `/v1/phone-numbers/{id}/whatsapp/calling`; same contract. New
        integrations should use that path.

        Enable WhatsApp Business Calling on a connected number. Configures
        Meta calling.status=ENABLED with our Telnyx SIP endpoint, fetches and
        stores the Meta-issued SIP password (encrypted), and snapshots the
        customer's forward-to destination.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: WhatsAppPhoneNumber Mongo ID }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, forwardTo]
              properties:
                accountId: { type: string }
                forwardTo: { type: string, description: "tel:+E164 / sip:... / wss://... destination" }
                sipAuthUsername: { type: string }
                sipAuthPassword: { type: string, description: "Stored encrypted, never returned by any endpoint." }
                recordingEnabled: { type: boolean, default: false }
                callIconCountries:
                  type: array
                  items: { type: string, minLength: 2, maxLength: 2 }
      responses:
        '200':
          description: Calling enabled
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  callingEnabled: { type: boolean }
                  sipHostname: { type: string }
                  forwardTo: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp phone number not found }
        '422': { description: "Not eligible to enable calling: not on usage-based billing, or the number's messaging limit is below Meta's ~2,000-daily-recipient threshold (TIER_250). Warm the number up to raise the limit." }
    patch:
      operationId: updateWhatsAppCallingLegacy
      deprecated: true
      tags: [WhatsApp Calling]
      summary: Update calling config
      description: |
        Deprecated alias of `/v1/phone-numbers/{id}/whatsapp/calling`; same contract. New
        integrations should use that path.

        Update fields on an already-enabled number. Only fields present in
        the body are written; `undefined` leaves the stored value alone,
        explicit `null` clears a nullable field. No Meta side effect, this
        only changes local routing state consumed by the Telnyx webhook
        handler.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId: { type: string }
                forwardTo: { type: string }
                sipAuthUsername: { type: [string, "null"] }
                sipAuthPassword: { type: [string, "null"] }
                recordingEnabled: { type: boolean }
                callIconCountries:
                  type: [array, "null"]
                  items: { type: string, minLength: 2, maxLength: 2 }
      responses:
        '200': { description: Updated }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp phone number not found }
        '422': { description: Calling must be enabled before settings can be updated }
    delete:
      operationId: disableWhatsAppCallingLegacy
      deprecated: true
      tags: [WhatsApp Calling]
      summary: Disable calling on a number
      description: |
        Deprecated alias of `/v1/phone-numbers/{id}/whatsapp/calling`; same contract. New
        integrations should use that path.

        Disable calling. Sends calling.status=DISABLED to Meta (best-effort)
        and flips the local `callingEnabled` flag off. forwardTo and SIP
        creds are preserved so a re-enable does not lose the destination.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
      responses:
        '200': { description: Disabled }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp phone number not found }

  /v1/whatsapp/call-permissions:
    get:
      operationId: getWhatsAppCallPermissions
      tags: [WhatsApp Calling]
      summary: Check call permission
      description: |
        Returns the permission state and the list of available actions for
        a given consumer wa_id (e.g. `start_call`, `send_call_permission_request`).
        Use this before placing a call to decide whether to prompt for
        consent first.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string } }
        - { name: to, in: query, required: true, schema: { type: string }, description: "Consumer wa_id (E.164, leading + optional)" }
      responses:
        '200':
          description: Permission state
          content:
            application/json:
              schema:
                type: object
                properties:
                  permission:
                    type: object
                    properties:
                      status: { type: string, enum: [temporary, no_permission, permanent] }
                      expiration_time: { type: integer, description: Unix seconds when temporary }
                  actions:
                    type: array
                    items:
                      type: object
                      properties:
                        action_name: { type: string, enum: [send_call_permission_request, start_call] }
                        can_perform_action: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }

  /v1/whatsapp/calls:
    post:
      operationId: initiateWhatsAppCall
      tags: [WhatsApp Calling]
      summary: Initiate outbound call
      description: |
        Initiates an outbound Business-Initiated Call. The Telnyx-side SIP
        leg is originated server-side (Option B: SIP-first). Telnyx INVITEs
        Meta directly over TLS:5061 with the SIP digest credentials we
        captured at calling-enablement time). No client-side SDP is
        required; pass only `accountId` and `to`.

        To send the consumer the call-consent prompt instead of placing a
        call, pass `action: "send_call_permission_request"` (+ optional
        `bodyText`). The consumer must tap Allow in WhatsApp before
        `start_call` is permitted; Meta limits the prompt to 1 per consumer
        per 24h (2 per 7 days) and requires an open 24h service window.

        **Idempotency:** send an `Idempotency-Key` header to make retries
        safe; same key + same body replays the original response instead of
        dialing (and billing) a second call.
      security:
        - bearerAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 255 }
          description: >-
            Optional client-generated unique key (e.g. a UUID) that makes
            dial retries safe. Same key + same body replays the original
            response; same key + different body → 422; key still processing →
            409.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, to]
              properties:
                accountId: { type: string }
                to: { type: string, description: "Consumer wa_id (E.164, leading + optional)" }
                action:
                  type: string
                  enum: [send_call_permission_request]
                  description: Omit to place a call. Set to send the consent prompt instead.
                bodyText:
                  type: string
                  maxLength: 1024
                  description: Body text shown with the consent prompt (send_call_permission_request only).
                forwardTo:
                  type: string
                  description: |
                    Per-call destination override. Same accepted shape as the
                    number's stored forwardTo (tel:+E164, sip:..., wss://...).
                recordOverride: { type: boolean }
                biz_opaque_callback_data:
                  type: string
                  maxLength: 512
                  description: |
                    Accepted for forward compatibility. Not currently echoed
                    back in webhook payloads (SIP-first flow does not pass
                    through Meta's Graph API where Meta would echo this).
      responses:
        '200':
          description: Call originated; lifecycle continues asynchronously via webhooks.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  callId: { type: string, description: Internal Call doc ID }
                  telnyxCallControlId: { type: string, description: Telnyx call_control_id of the outbound leg }
                  status: { type: string, enum: [dialing] }
                  direction: { type: string, enum: [outbound] }
                  to: { type: string }
                  forwardTo: { type: [string, "null"] }
                  recordingEnabled: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { description: No active call permission — send a permission request first. }
        '422': { description: "Calling not enabled, BIC country blocked, or missing Meta SIP credentials" }
        '502': { description: Telnyx-side originate failed; the Call doc has been marked failed. }
    get:
      operationId: listWhatsAppCalls
      tags: [WhatsApp Calling]
      summary: List call history for an account
      description: |
        Compact history listing for a single connected account. Results are
        scoped to the resolved SocialAccount; profile-scoped team members
        cannot read calls on sibling accounts.

        Cursor pagination: pass the returned `nextCursor` as `before` to fetch
        the next page (same scheme as `GET /v1/calls`). `since`/`until` remain
        as absolute range filters and combine with the cursor.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [ringing, answered, ended, failed] } }
        - { name: direction, in: query, schema: { type: string, enum: [inbound, outbound] } }
        - { name: since, in: query, schema: { type: string, format: date-time } }
        - { name: until, in: query, schema: { type: string, format: date-time } }
        - { name: before, in: query, schema: { type: string, format: date-time }, description: "Return calls with startedAt strictly before this instant (use the previous page's nextCursor)." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200 } }
      responses:
        '200':
          description: Calls
          content:
            application/json:
              schema:
                type: object
                properties:
                  calls:
                    type: array
                    items:
                      type: object
                      properties:
                        _id: { type: string }
                        direction: { type: string, enum: [inbound, outbound] }
                        from: { type: string }
                        to: { type: string }
                        status: { type: string, enum: [ringing, answered, ended, failed] }
                        startedAt: { type: string, format: date-time }
                        endedAt: { type: string, format: date-time }
                        durationSeconds: { type: integer }
                        endReason: { type: string, enum: [hangup, no_answer, rejected, error] }
                        recordingUrl: { type: string }
                        billing:
                          type: object
                          properties:
                            metaCostUSD: { type: number, description: 'Meta per-minute charge, billed by Meta directly to your WABA. Display only; not billed by Zernio.' }
                            telnyxCostUSD: { type: number }
                            recordingCostUSD: { type: number }
                            billableCostUSD: { type: number, description: 'Amount Zernio bills you = Telnyx leg + recording (excludes Meta).' }
                            totalCostUSD: { type: number, description: 'Full cost incl. the Meta portion you pay directly. Display only.' }
                            currency: { type: string }
                  nextCursor: { type: [string, "null"], format: date-time, description: Pass as `before` for the next page; null on the last page. }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/calls/{callId}:
    get:
      operationId: getWhatsAppCall
      tags: [WhatsApp Calling]
      summary: Get a single call
      security:
        - bearerAuth: []
      parameters:
        - { name: callId, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Call
          content:
            application/json:
              schema:
                type: object
                properties:
                  call:
                    type: object
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Call not found }

  /v1/whatsapp/calls/{callId}/recording:
    get:
      operationId: getWhatsAppCallRecording
      tags: [WhatsApp Calling]
      summary: Get a call recording
      description: |
        Resolves a fresh, playable MP3 URL for the call's recording.
        Provider-signed recording URLs expire ~10 minutes after signing, so the
        `recordingUrl` stored on the call is usually stale by the time it is
        played; this endpoint re-signs on demand. Default responds `302 Found`
        redirecting to the fresh URL (point an `<audio>` element or a link
        straight at this endpoint); pass `as=json` to receive `{ url }` instead.
      security:
        - bearerAuth: []
      parameters:
        - { name: callId, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
        - { name: as, in: query, schema: { type: string, enum: [json] }, description: "`json` returns `{ url }` instead of a 302 redirect." }
      responses:
        '302': { description: Redirect to a freshly-signed recording URL. }
        '200':
          description: Recording URL (`as=json` only).
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "Call not found, or no recording is available for this call" }
        '502': { description: Recording provider lookup failed }

  /v1/whatsapp/calls/estimate:
    get:
      operationId: getWhatsAppCallEstimate
      tags: [WhatsApp Calling]
      summary: Estimate per-minute cost
      description: |
        Returns a zero-markup estimated cost for an outbound call to the
        given destination, broken down by Meta + Telnyx + recording line
        items. Costs are pass-through, no margin applied.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string } }
        - { name: to, in: query, required: true, schema: { type: string } }
        - { name: minutes, in: query, schema: { type: integer, minimum: 1, maximum: 120 } }
        - { name: recording, in: query, schema: { type: boolean } }
      responses:
        '200':
          description: Estimate
          content:
            application/json:
              schema:
                type: object
                properties:
                  destinationCountry: { type: [string, "null"] }
                  perMinuteUsd: { type: number }
                  breakdown:
                    type: object
                    properties:
                      metaMinutes: { type: integer }
                      metaCostUSD: { type: number, description: 'Estimated Meta per-minute charge, billed by Meta directly to your WABA. Display only; not billed by Zernio.' }
                      telnyxCostUSD: { type: number }
                      recordingCostUSD: { type: number }
                      billableCostUSD: { type: number, description: 'Estimated amount Zernio bills you = Telnyx leg + recording (excludes Meta).' }
                      totalCostUSD: { type: number, description: 'Estimated full cost incl. the Meta portion you pay directly. Display only.' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/calls:
    get:
      operationId: listCalls
      tags: [Calls]
      summary: List all calls (unified history)
      description: |
        Unified call history across ALL of your numbers: both channels
        (WhatsApp Business Calling + regular phone/PSTN), inbound and outbound,
        newest first. Unlike `GET /v1/voice/calls` (PSTN-only) and
        `GET /v1/whatsapp/calls` (one account at a time), this endpoint needs no
        `accountId` and never requires fanning out one request per number.

        Any row can be opened channel-agnostically via `GET /v1/calls/{id}` and
        `GET /v1/calls/{id}/recording`; no branching on `channel` needed. When
        the counterparty number matches a CRM contact, `contactId` and
        `contactName` are set.

        Cursor pagination: pass the returned `nextCursor` as `before` to fetch
        the next page. `nextCursor` is null on the last page.
      security:
        - bearerAuth: []
      parameters:
        - { name: channel, in: query, schema: { type: string, enum: [whatsapp, pstn] } }
        - { name: status, in: query, schema: { type: string, enum: [ringing, answered, ended, failed] } }
        - { name: direction, in: query, schema: { type: string, enum: [inbound, outbound] } }
        - { name: number, in: query, schema: { type: string }, description: "Exact filter: calls involving this number (typically one of YOUR numbers, to scope history to a single line). E.164, leading + optional." }
        - { name: search, in: query, schema: { type: string }, description: "Free-text match on the from/to numbers. Non-digits are stripped, so partial queries like `302` or `+1 302` work." }
        - { name: before, in: query, schema: { type: string, format: date-time }, description: "Return calls with startedAt strictly before this instant (use the previous page's nextCursor)." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
      responses:
        '200':
          description: Calls, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  calls:
                    type: array
                    items:
                      allOf:
                        - $ref: '#/components/schemas/CallRecord'
                        - type: object
                          properties:
                            contactName: { type: string, description: "CRM contact name for the counterparty, when resolved." }
                  nextCursor: { type: [string, "null"], format: date-time, description: Pass as `before` for the next page; null on the last page. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Not enrolled in the calling beta }

  /v1/calls/{id}:
    get:
      operationId: getCall
      tags: [Calls]
      summary: Get a call (any channel)
      description: |
        Channel-agnostic call detail: works for both WhatsApp and regular
        phone (PSTN) calls, so any row from `GET /v1/calls` can be opened
        without branching on `channel`. Returns the full call including
        transcript segments, with `contactId`/`contactName` set when the
        counterparty matches a CRM contact.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Call
          content:
            application/json:
              schema:
                type: object
                properties:
                  call:
                    allOf:
                      - $ref: '#/components/schemas/CallRecord'
                      - type: object
                        properties:
                          contactName: { type: string, description: "CRM contact name for the counterparty, when resolved." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Not enrolled in the calling beta }
        '404': { description: Call not found }

  /v1/calls/{id}/recording:
    get:
      operationId: getCallRecording
      tags: [Calls]
      summary: Get a call recording (any channel)
      description: |
        Channel-agnostic recording fetch: resolves a fresh, playable MP3 URL
        for any call regardless of channel (provider-signed URLs expire ~10
        minutes after signing, so this re-signs on demand). Default responds
        `302 Found` redirecting to the fresh URL; pass `as=json` to receive
        `{ url }` instead.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: as, in: query, schema: { type: string, enum: [json] }, description: "`json` returns `{ url }` instead of a 302 redirect." }
      responses:
        '302': { description: Redirect to a freshly-signed recording URL. }
        '200':
          description: Recording URL (`as=json` only).
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Not enrolled in the calling beta }
        '404': { description: "Call not found, or no recording is available for this call" }
        '502': { description: Recording provider lookup failed }

  /v1/voice/calls:
    post:
      operationId: createVoiceCall
      tags: [Voice]
      summary: Place an outbound phone call
      description: |
        Dials `to` FROM one of your voice-enabled numbers and, on answer,
        bridges the callee to the number's stored forward destination, or to
        the per-call `forwardTo` override. Destinations can be your own AI
        voice agent (Vapi/Retell), a phone, or a SIP endpoint. An optional
        `greeting` is spoken to the callee before the bridge.

        The 200 response means the call is dialing; the lifecycle continues
        asynchronously (track it via `GET /v1/voice/calls/{id}` or the `call.*`
        webhooks). Outbound calls are capped per rolling hour (429 when hit).

        **Idempotency:** send an `Idempotency-Key` header to make retries safe;
        same key + same body replays the original response instead of dialing
        (and billing) a second call.
      security:
        - bearerAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 255 }
          description: >-
            Optional client-generated unique key (e.g. a UUID) that makes
            dial retries safe. Same key + same body replays the original
            response; same key + different body → 422; key still processing →
            409.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to]
              properties:
                to: { type: string, pattern: '^\+\d{6,15}$', description: "Destination to dial, E.164 with leading +." }
                fromNumber: { type: string, pattern: '^\+\d{6,15}$', description: Which of your voice-enabled numbers to dial from. Optional when you have exactly one. }
                forwardTo: { type: string, description: "Per-call agent override (tel:+E164, sip:..., or wss://...); defaults to the number's stored forward destination." }
                greeting: { type: string, maxLength: 1000, description: "Spoken to the callee when they answer, before the bridge." }
                recordOverride: { type: boolean, description: Per-call recording toggle; defaults to the number's setting. }
                transcribeOverride: { type: boolean, description: Per-call transcription toggle; defaults to the number's setting. }
                transcriptionLanguage: { type: string, enum: [auto, en, es], description: "'auto' derives from the callee's country; 'en'/'es' force it." }
                amd: { type: boolean, description: Answering-machine detection; defers the bridge until human vs machine is known. }
                voicemailDropMessage: { type: string, maxLength: 1000, description: "Spoken to a detected machine, then hang up (implies `amd`). For outbound voicemail drops." }
      responses:
        '200':
          description: Call originated; lifecycle continues asynchronously.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  callId: { type: string, description: Internal Call doc ID }
                  telnyxCallControlId: { type: string }
                  status: { type: string, enum: [dialing] }
                  direction: { type: string, enum: [outbound] }
                  from: { type: string }
                  to: { type: string }
                  forwardTo: { type: string }
                  greeting: { type: [string, "null"] }
                  recordingEnabled: { type: boolean }
                  transcriptionEnabled: { type: boolean }
                  transcriptionLanguage: { type: string, enum: [auto, en, es] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { description: "No voice-enabled number matches `fromNumber`, or no forward destination configured (set the number's forward or pass `forwardTo`)." }
        '429': { description: Outbound call limit reached (per rolling hour). }
        '502': { description: Carrier-side originate failed; the call has been marked failed. }
    get:
      operationId: listVoiceCalls
      tags: [Voice]
      summary: List phone calls
      description: |
        Your PSTN voice calls (inbound + outbound), newest first. Cursor
        pagination: pass the returned `nextCursor` as `before` for the next
        page. For a history that also includes WhatsApp calls, use
        `GET /v1/calls`.
      security:
        - bearerAuth: []
      parameters:
        - { name: status, in: query, schema: { type: string, enum: [ringing, answered, ended, failed] } }
        - { name: direction, in: query, schema: { type: string, enum: [inbound, outbound] } }
        - { name: number, in: query, schema: { type: string }, description: "Exact filter: calls involving this number (typically one of your DIDs). E.164, leading + optional." }
        - { name: before, in: query, schema: { type: string, format: date-time } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
      responses:
        '200':
          description: Calls, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  calls:
                    type: array
                    items:
                      $ref: '#/components/schemas/CallRecord'
                  nextCursor: { type: [string, "null"], format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/voice/calls/{id}:
    get:
      operationId: getVoiceCall
      tags: [Voice]
      summary: Get a phone call
      description: Full call detail, including the transcript segments when transcription was on.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Call
          content:
            application/json:
              schema:
                type: object
                properties:
                  call:
                    $ref: '#/components/schemas/CallRecord'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Call not found }

  /v1/voice/calls/{id}/end:
    post:
      operationId: endVoiceCall
      tags: [Voice]
      summary: Hang up a live call
      description: |
        Hangs up a live call on demand. Idempotent: ending a call that already
        ended (or never connected) returns success with the call's current
        status. Final duration/cost are written asynchronously when the hangup
        event lands, so the call doc may briefly still show its prior status.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Hangup issued (or the call was already over).
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  callId: { type: string }
                  status: { type: string, description: "`ending` when a hangup was issued; otherwise the call's current status." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Call not found }
        '502': { description: Carrier-side hangup failed }

  /v1/voice/calls/{id}/recording:
    get:
      operationId: getVoiceCallRecording
      tags: [Voice]
      summary: Get a call recording
      description: |
        Resolves a fresh, playable MP3 URL for the call's recording
        (provider-signed URLs expire ~10 minutes after signing, so this
        endpoint re-signs on demand). Default responds `302 Found` redirecting
        to the fresh URL; pass `as=json` to receive `{ url }` instead.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: as, in: query, schema: { type: string, enum: [json] }, description: "`json` returns `{ url }` instead of a 302 redirect." }
      responses:
        '302': { description: Redirect to a freshly-signed recording URL. }
        '200':
          description: Recording URL (`as=json` only).
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "Call not found, or no recording is available for this call" }
        '502': { description: Recording provider lookup failed }

  /v1/voice/calls/{id}/transfer:
    post:
      operationId: transferVoiceCall
      tags: [Voice]
      summary: Blind-transfer a live call
      description: |
        Moves the call's current leg to a new destination (a phone number or a
        SIP endpoint). This is a BLIND transfer: control of the leg is handed
        off and the call ends normally when the transferred leg hangs up. The
        caller ID presented on the transfer leg is always your own number.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to]
              properties:
                to: { type: string, description: "+E164 phone number (tel: prefix optional) or a sip: URI. wss:// is not a valid transfer target." }
      responses:
        '200':
          description: Transfer issued.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  callId: { type: string }
                  transferredTo: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Call not found }
        '409': { description: "Call is not connected yet, or has already ended" }

  /v1/voice/calls/estimate:
    get:
      operationId: getVoiceCallEstimate
      tags: [Voice]
      summary: Estimate call cost
      description: |
        Pre-call cost estimate for a PSTN call: the carrier leg plus optional
        recording and transcription add-ons. Same billing formula as the
        post-call invoice, so the quote and the final charge can't disagree.
        The per-minute figure is deliberately conservative (the real cost
        comes from the settled carrier record after the call), so estimates
        trend slightly over the actual invoice. Parity endpoint of
        `GET /v1/whatsapp/calls/estimate`, minus the Meta line (PSTN calls
        have no separate Meta bill, so `totalCostUSD` equals
        `billableCostUSD`).
      security:
        - bearerAuth: []
      parameters:
        - { name: to, in: query, required: true, schema: { type: string }, description: "Destination number, E.164 (leading + optional)." }
        - { name: minutes, in: query, schema: { type: integer, minimum: 1, maximum: 120, default: 1 } }
        - { name: recording, in: query, schema: { type: boolean } }
        - { name: transcription, in: query, schema: { type: boolean } }
      responses:
        '200':
          description: Estimate
          content:
            application/json:
              schema:
                type: object
                properties:
                  destinationCountry: { type: [string, "null"] }
                  minutes: { type: integer }
                  perMinuteUsd: { type: number, description: Billable cost per minute for the requested options. }
                  breakdown:
                    type: object
                    properties:
                      telnyxCostUSD: { type: number }
                      recordingCostUSD: { type: number }
                      transcriptionCostUSD: { type: number }
                      billableCostUSD: { type: number, description: What Zernio bills for the call. }
                      totalCostUSD: { type: number, description: Equals billableCostUSD (no separate Meta bill on PSTN); kept for shape parity with the WhatsApp estimate. }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/voice/calls/web:
    post:
      operationId: createVoiceWebSession
      tags: [Voice]
      summary: Mint a browser softphone session
      description: |
        Step 1 of the two-step browser softphone handshake. Mints a WebRTC
        session (token + credential) the browser registers with the
        `@telnyx/webrtc` SDK. Once registered, call
        `POST /v1/voice/calls/web/dial` with the returned `credentialId` to
        place the call. The split avoids bridging to a browser that has not
        finished registering. The token lives ~1 hour (it must outlive the
        whole call, not just the handshake).
      security:
        - bearerAuth: []
      responses:
        '200':
          description: WebRTC session minted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  token: { type: string, description: "Login token for the browser WebRTC SDK." }
                  credentialId: { type: string, description: "Pass to POST /v1/voice/calls/web/dial once the browser is registered." }
                  expiresAt: { type: string, format: date-time }
                  sdk: { type: string, example: '@telnyx/webrtc' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '502': { description: Failed to mint the WebRTC session }

  /v1/voice/calls/web/dial:
    post:
      operationId: dialVoiceWebCall
      tags: [Voice]
      summary: Dial from the browser softphone
      description: |
        Step 2 of the browser softphone handshake: places an outbound call
        whose answered leg is bridged to the browser registered with the
        credential from `POST /v1/voice/calls/web`. The call runs through the
        normal outbound lane, so it is logged as outbound (from = your number,
        to = target) and recorded per the number's settings.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [to, credentialId]
              properties:
                to: { type: string, pattern: '^\+\d{6,15}$', description: "The number to call, E.164 with leading +." }
                credentialId: { type: string, description: "The WebRTC credential id returned by POST /v1/voice/calls/web (the registered browser)." }
                fromNumber: { type: string, pattern: '^\+\d{6,15}$', description: Which of your voice-enabled numbers to call from (optional when you have one). }
                recordOverride: { type: boolean }
      responses:
        '200':
          description: Call originated; answer/bridge continue asynchronously.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  callId: { type: string }
                  telnyxCallControlId: { type: string }
                  status: { type: string, enum: [dialing] }
                  direction: { type: string, enum: [outbound] }
                  from: { type: string }
                  to: { type: string }
                  recordingEnabled: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { description: "Invalid or unknown WebRTC credential, or no voice-enabled number matches `fromNumber`" }
        '429': { description: Outbound call limit reached (per rolling hour). }
        '502': { description: Carrier-side originate failed }

  /v1/sms/messages:
    post:
      operationId: sendSms
      tags: [SMS]
      summary: Send an SMS/MMS
      description: |
        Sends an SMS (or MMS when `mediaUrls` is set) from one of your
        SMS-enabled numbers. At least one of `text` / `mediaUrls` is required.
        Both numbers are normalized to E.164, so `from` matches regardless of
        formatting and replies thread into the same inbox conversation.

        US numbers must have an approved carrier registration
        (`/v1/sms/registrations`) before messages deliver.

        **Idempotency:** send an `Idempotency-Key` header to make retries safe:
        same key + same body replays the original response instead of sending a
        second message; same key + different body returns 422; a key still in
        flight returns 409.
      security:
        - bearerAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 255 }
          description: >-
            Optional client-generated unique key (e.g. a UUID) that makes send
            retries safe. Same key + same body replays the original response;
            same key + different body → 422; key still processing → 409.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [from, to]
              properties:
                from: { type: string, description: One of your SMS-enabled numbers (E.164; formatting is normalized). }
                to: { type: string, description: Recipient number (E.164). }
                text: { type: string, description: "Message body. Required unless `mediaUrls` is set. Max 10 SMS segments (1530 GSM-7 or 670 unicode characters)." }
                mediaUrls:
                  type: array
                  maxItems: 10
                  items: { type: string, format: uri }
                  description: Public media URLs to attach (sends as MMS). Max 10.
      responses:
        '200':
          description: Message accepted for delivery.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, description: Message ID }
                  conversationId: { type: string, description: Inbox conversation the message was threaded into. }
                  status: { type: string, enum: [sent] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "No SMS-enabled number matches `from`" }
        '409': { description: Same Idempotency-Key still processing; retry after a short backoff }
        '422': { description: Idempotency-Key reused with a different body }
        '502': { description: Carrier-side send failed }

  /v1/sms/lookup:
    get:
      operationId: lookupSmsNumber
      tags: [SMS]
      summary: Look up carrier + line type
      description: |
        Carrier name and line type (mobile / landline / voip / toll-free) for a
        number, plus `smsReachable` (landlines can't receive SMS). Use it to
        validate recipients before sending. Each lookup is billed by the
        carrier-data provider, so call it explicitly (e.g. pre-validating an
        opt-in list), not on every send.
      security:
        - bearerAuth: []
      parameters:
        - { name: number, in: query, required: true, schema: { type: string, minLength: 8 }, description: "Number to look up (E.164; formatting is normalized)." }
      responses:
        '200':
          description: Lookup result. An unknown/invalid number returns lineType `unknown` with `smsReachable` false rather than an error.
          content:
            application/json:
              schema:
                type: object
                properties:
                  phoneNumber: { type: string }
                  carrierName: { type: [string, "null"] }
                  lineType: { type: string, enum: [mobile, landline, voip, toll-free, unknown] }
                  smsReachable: { type: boolean, description: True when the line type can receive SMS (not a landline). }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '502': { description: Lookup provider failed }

  /v1/sms/opt-outs:
    get:
      operationId: listSmsOptOuts
      tags: [SMS]
      summary: List SMS opt-outs
      description: |
        The recipients who opted out of SMS (replied STOP) across your numbers,
        most recent first. Compliance surface: you must be able to see and
        export your opt-out list. Read-only: a recipient is re-subscribed only
        by replying START. Pass `format=csv` to download a CSV instead of JSON.
      security:
        - bearerAuth: []
      parameters:
        - { name: format, in: query, schema: { type: string, enum: [json, csv], default: json } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 5000, default: 500 } }
      responses:
        '200':
          description: Opt-out list
          content:
            application/json:
              schema:
                type: object
                properties:
                  optOuts:
                    type: array
                    items:
                      type: object
                      properties:
                        phoneNumber: { type: string }
                        optedOutAt: { type: [string, "null"], format: date-time }
                        keyword: { type: [string, "null"], description: "The keyword they sent (e.g. STOP), when the carrier recorded one." }
                        from: { type: [string, "null"], description: Which of your numbers the recipient opted out from. }
                  count: { type: integer }
            text/csv:
              schema: { type: string, description: "`format=csv` download. Columns: phone_number, opted_out_at, keyword, our_number" }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/sms/registrations:
    post:
      operationId: startSmsRegistration
      tags: [SMS]
      summary: Start a carrier registration
      description: |
        Starts the US carrier registration that a number needs before SMS
        delivers: 10DLC (standard company or sole-proprietor) or toll-free
        verification. 10DLC needs `brand` + `campaign`; toll-free needs
        `tollFree`. Approval is asynchronous; poll
        `GET /v1/sms/registrations/{id}` (sole-prop registrations first need
        the OTP step: a code is texted to the brand's mobile number, submit it
        via `/verify-otp`).

        Already have an approved registration? Add another number to it with
        `POST /v1/phone-numbers/{id}/sms/reuse-registration` instead
        of registering (and paying the carrier brand fee) again.

        Rather have your client fill in the legal business details? Create a
        share link with `POST /v1/sms/registrations/share`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [registrationType, phoneNumbers]
              properties:
                registrationType: { type: string, enum: [standard_10dlc, sole_prop_10dlc, toll_free] }
                phoneNumbers:
                  type: array
                  minItems: 1
                  items: { type: string }
                  description: Your numbers this registration covers.
                brand:
                  type: object
                  description: Required for 10DLC. The legal entity behind the traffic (TCR brand).
                  required: [entityType, displayName, country, vertical]
                  properties:
                    entityType: { type: string, enum: [PRIVATE_PROFIT, PUBLIC_PROFIT, NON_PROFIT, GOVERNMENT, SOLE_PROPRIETOR] }
                    displayName: { type: string }
                    companyName: { type: string, description: Legal company name. Required for every entityType except SOLE_PROPRIETOR. }
                    ein: { type: string, description: Required for every entityType except SOLE_PROPRIETOR. }
                    phone: { type: string }
                    mobilePhone: { type: string, description: Required for SOLE_PROPRIETOR; the verification OTP is texted there (US/CA mobile). }
                    street: { type: string }
                    city: { type: string }
                    state: { type: string }
                    postalCode: { type: string }
                    country: { type: string, enum: [US, CA] }
                    email: { type: string, format: email, description: Brand contact email; defaults to your account email when omitted. }
                    website: { type: string }
                    vertical: { type: string, enum: [AGRICULTURE, COMMUNICATION, CONSTRUCTION, EDUCATION, ENERGY, ENTERTAINMENT, FINANCIAL, GAMBLING, GOVERNMENT, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, LEGAL, MANUFACTURING, NGO, POLITICAL, POSTAL, PROFESSIONAL, REAL_ESTATE, RETAIL, TECHNOLOGY, TRANSPORTATION] }
                    stockSymbol: { type: string }
                campaign:
                  type: object
                  description: Required for 10DLC. What you'll send and how recipients opt in/out.
                  required: [usecase, description, messageFlow, sample1, helpMessage, optinKeywords, optinMessage, optoutKeywords, optoutMessage, helpKeywords]
                  properties:
                    usecase: { type: string }
                    description: { type: string, minLength: 40, maxLength: 4096 }
                    messageFlow: { type: string, minLength: 40, maxLength: 2048, description: How a recipient ends up receiving your messages (the opt-in flow). }
                    sample1: { type: string, minLength: 20 }
                    sample2: { type: string }
                    helpMessage: { type: string, minLength: 20, maxLength: 320 }
                    optinKeywords: { type: string }
                    optinMessage: { type: string, minLength: 20, maxLength: 320 }
                    optoutKeywords: { type: string }
                    optoutMessage: { type: string, minLength: 20, maxLength: 320 }
                    helpKeywords: { type: string }
                    embeddedLink: { type: boolean }
                    embeddedPhone: { type: boolean }
                    numberPool: { type: boolean }
                    ageGated: { type: boolean }
                    directLending: { type: boolean }
                tollFree:
                  type: object
                  description: Required for toll_free.
                  required: [businessName, corporateWebsite, phoneNumbers, useCase, useCaseSummary, productionMessageContent, optInWorkflow, optInWorkflowImageUrls, messageVolume, additionalInformation, businessAddr1, businessCity, businessState, businessZip, businessContactFirstName, businessContactLastName, businessContactEmail, businessContactPhone, businessRegistrationNumber, businessRegistrationType, businessRegistrationCountry]
                  properties:
                    businessName: { type: string }
                    corporateWebsite: { type: string, format: uri }
                    phoneNumbers: { type: array, minItems: 1, items: { type: string } }
                    useCase: { type: string }
                    useCaseSummary: { type: string }
                    productionMessageContent: { type: string }
                    optInWorkflow: { type: string, maxLength: 500, description: How recipients opt in to your messages. }
                    optInWorkflowImageUrls:
                      type: array
                      minItems: 1
                      items: { type: string, format: uri }
                      description: Screenshot URL(s) showing the opt-in flow (at least one).
                    messageVolume: { type: string, enum: ["10", "100", "1,000", "10,000", "100,000", "250,000", "500,000", "750,000", "1,000,000", "5,000,000", "10,000,000+"], description: Expected monthly message volume tier. }
                    additionalInformation: { type: string }
                    businessAddr1: { type: string }
                    businessAddr2: { type: string }
                    businessCity: { type: string }
                    businessState: { type: string }
                    businessZip: { type: string }
                    businessContactFirstName: { type: string }
                    businessContactLastName: { type: string }
                    businessContactEmail: { type: string, format: email }
                    businessContactPhone: { type: string }
                    businessRegistrationNumber: { type: string }
                    businessRegistrationType: { type: string, description: "e.g. EIN (US), Companies House (UK), ABN (AU)." }
                    businessRegistrationCountry: { type: string, description: ISO 3166-1 alpha-2. }
      responses:
        '200':
          description: Registration submitted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  registrationId: { type: string }
                  status: { type: string, enum: [pending] }
                  awaitingOtp: { type: boolean, description: "True for sole-prop 10DLC: an OTP was texted to the brand's mobile; submit it via /verify-otp." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { description: Carrier registry rejected a field; `param` names it when known. }
    get:
      operationId: listSmsRegistrations
      tags: [SMS]
      summary: List carrier registrations
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Registrations, newest first
          content:
            application/json:
              schema:
                type: object
                properties:
                  registrations:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        registrationType: { type: string, enum: [standard_10dlc, sole_prop_10dlc, toll_free] }
                        displayName: { type: [string, "null"] }
                        status: { type: string, enum: [pending, approved, rejected] }
                        brandStatus: { type: string, description: Carrier-registry brand status (e.g. VERIFIED). }
                        campaignStatus: { type: string }
                        brandId: { type: [string, "null"], description: "TCR brand id, useful when referencing the brand in carrier support threads." }
                        campaignId: { type: [string, "null"], description: TCR campaign id. }
                        declineReason: { type: [string, "null"] }
                        phoneNumbers: { type: array, items: { type: string } }
                        awaitingOtp: { type: boolean, description: Sole-prop 10DLC only; the OTP step is still pending. }
                        trustScore: { type: [number, "null"], description: Carrier-assigned brand trust score; drives throughput. }
                        throughput:
                          type: object
                          description: Carrier throughput tier derived from the trust score.
                          properties:
                            label: { type: string }
                            smsPerMinute: { type: number }
                            smsPerDay: { type: number }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/sms/registrations/{id}:
    get:
      operationId: getSmsRegistration
      tags: [SMS]
      summary: Get a carrier registration
      description: Poll this for approval progress after starting a registration.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Registration
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  registrationType: { type: string, enum: [standard_10dlc, sole_prop_10dlc, toll_free] }
                  status: { type: string, enum: [pending, approved, rejected] }
                  brandStatus: { type: string }
                  campaignStatus: { type: string }
                  declineReason: { type: [string, "null"] }
                  phoneNumbers: { type: array, items: { type: string } }
                  awaitingOtp: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Registration not found }

  /v1/sms/registrations/{id}/verify-otp:
    post:
      operationId: verifySmsRegistrationOtp
      tags: [SMS]
      summary: Submit the sole-prop OTP
      description: |
        Completes sole-proprietor 10DLC brand verification by submitting the
        one-time PIN texted to the brand's mobile number. On success the
        registration continues to campaign creation automatically.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [otpPin]
              properties:
                otpPin: { type: string, minLength: 4, maxLength: 10 }
      responses:
        '200':
          description: OTP result
          content:
            application/json:
              schema:
                type: object
                properties:
                  verified: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Registration not found }

  /v1/sms/registrations/{id}/appeal:
    post:
      operationId: appealSmsRegistration
      tags: [SMS]
      summary: Appeal a rejected campaign
      description: |
        Appeals a rejected 10DLC campaign with the carrier registry. Only a
        registration that reached campaign creation can be appealed; a
        brand-level rejection should be fixed and re-verified instead. On
        success the registration returns to `pending`.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [appealReason]
              properties:
                appealReason: { type: string, minLength: 10, maxLength: 2000 }
      responses:
        '200':
          description: Appeal submitted; the registration is pending again.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [pending] }
        '400': { description: Registration has no campaign to appeal; fix the brand and re-verify instead }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Registration not found }

  /v1/sms/registrations/share:
    post:
      operationId: shareSmsRegistration
      tags: [SMS]
      summary: Create a registration share link
      description: |
        Creates a single-use, expiring link (valid 7 days) that lets someone
        else (whoever has the legal business details) fill in the carrier
        registration form for one of your numbers, without a Zernio login. The
        registration is created under your account once the form is submitted.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [numberId]
              properties:
                numberId: { type: string, description: "Your phone number's ID (from GET /v1/phone-numbers)." }
      responses:
        '200':
          description: Share link created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string }
                  expiresAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Number not found }

  /v1/whatsapp/template-library:
    get:
      operationId: getWhatsAppLibraryTemplate
      tags: [WhatsApp Templates]
      summary: Look up a library template
      description: |
        Look up a single pre-approved Template Library template by its exact name, to
        introspect its structure before importing it. Most importantly it returns the
        template's `buttons`: a library template with `URL` / `PHONE_NUMBER` buttons
        must be created with a matching `library_template_button_inputs` array (see
        Create Template), or Meta rejects it. Use this to discover which inputs to collect.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
        - { name: name, in: query, required: true, schema: { type: string }, description: Exact library template name }
      responses:
        '200':
          description: Library template (or null if no exact match)
          content:
            application/json:
              schema:
                type: object
                properties:
                  template:
                    type: [object, "null"]
                    properties:
                      name: { type: string }
                      language: { type: string }
                      category: { type: string }
                      body: { type: string }
                      body_params: { type: array, items: { type: string } }
                      buttons:
                        type: array
                        items:
                          type: object
                          properties:
                            type: { type: string, description: "QUICK_REPLY, URL, PHONE_NUMBER, OTP, FLOW, ..." }
                            text: { type: string }
        '400': { description: Missing or invalid query params }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }

  # ──────────────────────────────────────────────────────────────────────────
  # 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 }

  /v1/whatsapp/business-profile/username:
    get:
      operationId: getWhatsappBusinessUsername
      tags: [WhatsApp]
      summary: Get business username
      description: |
        Fetch the current WhatsApp Business username and its approval status.
        Username status can be `approved` (active), `reserved` (pending activation), or `none` (no username set).
      security:
        - bearerAuth: []
      parameters:
        - name: accountId
          in: query
          required: true
          description: WhatsApp social account ID
          schema:
            type: string
      responses:
        '200':
          description: Business username retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  username:
                    type: [string, "null"]
                    description: The current username, or null if none is set
                  status:
                    type: string
                    enum: [approved, reserved, none]
                    description: Approval state of the username
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }
    post:
      operationId: setWhatsappBusinessUsername
      tags: [WhatsApp]
      summary: Set business username
      description: |
        Claim or transfer a WhatsApp Business username for the account.

        Username rules: 3-35 characters, letters/digits/period/underscore only, must contain at least one letter,
        no leading or trailing periods, no consecutive periods, no `www` prefix, no domain TLD suffix (e.g. `.com`).

        If the desired username is currently held by another account, pass `transferAction: "force_transfer"` to
        request a transfer. On failure the API returns a standard error envelope with one of these codes:
        `whatsapp_username_unavailable` (already taken and transfer not requested),
        `whatsapp_username_ineligible` (account not eligible to claim a username), or
        `whatsapp_username_transfer_required` (username is held elsewhere; retry with `force_transfer`).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, username]
              properties:
                accountId:
                  type: string
                  description: WhatsApp social account ID
                username:
                  type: string
                  minLength: 3
                  maxLength: 35
                  description: >
                    Desired username. Letters, digits, period, and underscore only.
                    Must contain at least one letter. No leading, trailing, or consecutive periods.
                    No www prefix. No domain TLD suffix.
                transferAction:
                  type: string
                  enum: [none, force_transfer]
                  default: none
                  description: Pass `force_transfer` to request a transfer if the username is held by another account
            example:
              accountId: "507f1f77bcf86cd799439011"
              username: "mybusiness"
              transferAction: "none"
      responses:
        '200':
          description: Username claimed successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  username: { type: string }
                  status:
                    type: string
                    enum: [approved, reserved, none]
              example:
                success: true
                username: "mybusiness"
                status: "approved"
        '400': { description: "Validation error or username unavailable (see error code in response)" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }
    delete:
      operationId: deleteWhatsappBusinessUsername
      tags: [WhatsApp]
      summary: Delete business username
      description: |
        Release the currently claimed WhatsApp Business username from the account.
        After deletion the username becomes available for other accounts to claim.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId:
                  type: string
                  description: WhatsApp social account ID
            example:
              accountId: "507f1f77bcf86cd799439011"
      responses:
        '200':
          description: Username deleted successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
              example:
                success: true
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }

  /v1/whatsapp/business-profile/username/suggestions:
    get:
      operationId: getWhatsappBusinessUsernameSuggestions
      tags: [WhatsApp]
      summary: Get username suggestions
      description: |
        Retrieve a list of available WhatsApp Business username suggestions based on the account's
        business profile name. Use these to help users discover valid, unclaimed usernames.
      security:
        - bearerAuth: []
      parameters:
        - name: accountId
          in: query
          required: true
          description: WhatsApp social account ID
          schema:
            type: string
      responses:
        '200':
          description: Username suggestions retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  suggestions:
                    type: array
                    items: { type: string }
                    description: List of available username suggestions
              example:
                success: true
                suggestions: ["mybusiness", "mybusiness_official", "mybiz"]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }

  /v1/whatsapp/number-info:
    get:
      operationId: getWhatsAppNumberInfo
      tags: [WhatsApp Phone Numbers]
      summary: Get number status
      description: |
        Live snapshot of a connected number straight from Meta: the phone-number node
        (display number, display name + approval, quality rating, messaging-limit tier,
        throughput, official-business badge, connection status, health_status) and its
        owning WhatsApp Business Account (name, business verification, timezone,
        health_status). Fetched live because Meta updates quality/tier/name/health over
        time; the call also refreshes the cached values shown on the connection card.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
      responses:
        '200':
          description: Number + WABA status
          content:
            application/json:
              schema:
                type: object
                properties:
                  phone:
                    type: object
                    properties:
                      display_phone_number: { type: string }
                      verified_name: { type: string }
                      name_status: { type: string, description: "APPROVED, AVAILABLE_WITHOUT_REVIEW, PENDING_REVIEW, DECLINED, EXPIRED, NONE" }
                      quality_rating: { type: string, description: "GREEN, YELLOW, RED, UNKNOWN" }
                      messaging_limit_tier: { type: string, description: "e.g. TIER_250, TIER_1K, TIER_UNLIMITED" }
                      throughput: { type: object, properties: { level: { type: string, description: "STANDARD or HIGH" } } }
                      status: { type: string, description: "e.g. CONNECTED" }
                      is_official_business_account: { type: boolean }
                      platform_type: { type: string, description: "e.g. CLOUD_API" }
                      health_status: { type: object, description: Meta's can_send_message health object (messaging + calling signals) }
                  waba:
                    type: [object, "null"]
                    properties:
                      name: { type: string }
                      business_verification_status: { type: string, description: "verified, not_verified, pending, ..." }
                      timezone_id: { type: string, description: Meta integer timezone-enum id }
                      health_status: { type: object }
        '400': { description: Phone number ID not found on account }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }

  /v1/whatsapp/block-users/status:
    get:
      operationId: getWhatsAppBlockStatus
      tags: [WhatsApp]
      summary: Check if a user is blocked
      description: |
        Definitive blocked-state lookup for a single contact. Meta exposes no
        membership endpoint, so this reads Zernio's blocklist mirror (kept in
        sync by the block/unblock endpoints; the first call per account
        backfills the mirror from Meta's full list). Constant-time regardless
        of blocklist size.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string } }
        - { name: user, in: query, required: true, schema: { type: string }, description: Consumer wa_id or E.164 phone (leading + optional) }
      responses:
        '200':
          description: Blocked state
          content:
            application/json:
              schema:
                type: object
                properties:
                  blocked: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }

  /v1/whatsapp/block-users:
    get:
      operationId: getWhatsAppBlockedUsers
      tags: [WhatsApp]
      summary: List blocked users
      description: |
        List the WhatsApp users blocked on this number. Cursor-paginated; pass
        `nextCursor` back as `after` to fetch the next page. The blocklist holds
        up to 64,000 users.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100 }, description: Page size. }
        - { name: after, in: query, required: false, schema: { type: string }, description: Cursor from a previous response's `nextCursor`. }
      responses:
        '200':
          description: Blocked users
          content:
            application/json:
              schema:
                type: object
                properties:
                  blockedUsers:
                    type: array
                    items:
                      type: object
                      properties:
                        waId: { type: string, description: "WhatsApp user ID (usually the phone number without `+`)." }
                  nextCursor: { type: [string, "null"], description: Pass as `after` to fetch the next page. Null when there are no more pages. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }
    post:
      operationId: blockWhatsAppUsers
      tags: [WhatsApp]
      summary: Block users
      description: |
        Block one or more WhatsApp users on this number. Blocked users cannot
        message your number or see that you are online, and your sends to them
        return an error.

        Meta constraints, surfaced per-user in `failed` (the request itself still
        succeeds for the rest of the batch):
        - Only users who messaged your business within the last 24 hours can be
          blocked (failures outside the window report "Re-engagement required").
        - Up to 1,000 users per request; the blocklist caps at 64,000.
        - Other WhatsApp Business accounts cannot be blocked.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, users]
              properties:
                accountId: { type: string, description: WhatsApp social account ID }
                users:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items: { type: string }
                  description: Phone numbers (E.164, e.g. "+16505551234") or WhatsApp user IDs to block.
      responses:
        '200':
          description: Per-user results
          content:
            application/json:
              schema:
                type: object
                properties:
                  blocked:
                    type: array
                    description: Users successfully blocked.
                    items:
                      type: object
                      properties:
                        input: { type: string, description: The value you sent. }
                        waId: { type: string, description: Resolved WhatsApp user ID. }
                  failed:
                    type: array
                    description: Users that could not be blocked, with reasons.
                    items:
                      type: object
                      properties:
                        input: { type: string }
                        errors: { type: array, items: { type: string } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }
    delete:
      operationId: unblockWhatsAppUsers
      tags: [WhatsApp]
      summary: Unblock users
      description: |
        Unblock one or more previously blocked WhatsApp users on this number.
        Up to 1,000 users per request; per-user failures are reported in
        `failed` without failing the rest of the batch.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, users]
              properties:
                accountId: { type: string, description: WhatsApp social account ID }
                users:
                  type: array
                  minItems: 1
                  maxItems: 1000
                  items: { type: string }
                  description: Phone numbers (E.164) or WhatsApp user IDs to unblock.
      responses:
        '200':
          description: Per-user results
          content:
            application/json:
              schema:
                type: object
                properties:
                  unblocked:
                    type: array
                    description: Users successfully unblocked.
                    items:
                      type: object
                      properties:
                        input: { type: string, description: The value you sent. }
                        waId: { type: string, description: Resolved WhatsApp user ID. }
                  failed:
                    type: array
                    description: Users that could not be unblocked, with reasons.
                    items:
                      type: object
                      properties:
                        input: { type: string }
                        errors: { type: array, items: { type: string } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }

  /v1/whatsapp/dataset:
    get:
      operationId: getWhatsAppDataset
      tags: [WhatsApp]
      summary: Get CTWA conversions dataset
      description: |
        Returns the Meta Click-to-WhatsApp conversions dataset currently linked
        to the WhatsApp account, if one has been provisioned. Reads only from
        the stored `metadata.metaCapiDatasetId` — never hits Meta, never
        creates a dataset. Use this to detect whether `POST /v1/whatsapp/conversions`
        is configured for an account.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
      responses:
        '200':
          description: Dataset lookup
          content:
            application/json:
              schema:
                type: object
                properties:
                  datasetId:
                    type: [string, "null"]
                    description: Meta dataset ID linked to the WABA, or null if not provisioned yet
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }
    post:
      operationId: createWhatsAppDataset
      tags: [WhatsApp]
      summary: Provision CTWA dataset
      description: |
        Creates (or fetches, if one already exists) the Meta dataset that
        Click-to-WhatsApp ad events are reported against via the Conversions
        API, and persists its ID on the account as `metadata.metaCapiDatasetId`.

        The call is GET-first idempotent — a WABA can only own one CTWA
        dataset, so a second call after a successful provision is a safe no-op
        that returns the same ID with `created: false`.

        Requires the connected WhatsApp account's token to carry the
        `whatsapp_business_manage_events` permission. If the permission is
        missing the endpoint returns 422 with a message asking the user to
        reconnect the account.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId:
                  type: string
                  description: WhatsApp social account ID
      responses:
        '200':
          description: Dataset provisioned (or already present)
          content:
            application/json:
              schema:
                type: object
                properties:
                  datasetId:
                    type: string
                    description: Meta dataset ID linked to the WABA
                  created:
                    type: boolean
                    description: True if Meta created a new dataset on this call; false if one already existed
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }
        '422': { description: Account is missing `whatsapp_business_manage_events` — reconnect required }
        '502': { description: Upstream Meta failure during provisioning }

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

  # ─── Phone Numbers (platform-independent) ──────────────────────
  #
  # A phone number is the unit you buy; WhatsApp, SMS, and Voice are features
  # enabled on it. This is the canonical namespace; the
  # /v1/whatsapp/phone-numbers/* paths further down are deprecated aliases
  # kept for back-compat.

  /v1/phone-numbers:
    get:
      operationId: listPhoneNumbers
      tags: [Phone Numbers]
      summary: List phone numbers
      description: |
        List all phone numbers purchased by the authenticated user.
        By default, released numbers are excluded. Connected (bring-your-own)
        WhatsApp numbers are returned in the separate `connected` array; they
        are not billed and have no provisioning lifecycle.
      security:
        - bearerAuth: []
      parameters:
        - name: status
          in: query
          required: false
          description: |
            Filter by status (by default excludes released numbers). NOTE:
            `status=pending_regulatory` returns the "provisioning" view — numbers
            still in review PLUS recently-declined (last 30 days) ones, so a
            failed registration surfaces (with `regulatoryDeclineReason`) instead
            of silently disappearing. Declined numbers can be re-submitted via
            POST /v1/phone-numbers/{id}/remediate. `verifying` is the
            short-lived state after the number is provisioned on our side while
            WhatsApp confirms the activation code; the number is not billed until
            it reaches `active`.
          schema:
            type: string
            enum: [provisioning, verifying, pending_payment, pending_regulatory, regulatory_declined, 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, pending_regulatory, regulatory_declined, provisioning, verifying, active, suspended, releasing, released] }
                        registrantName: { type: [string, "null"], description: "For regulated numbers, who it's registered for (company or person) — set from the submitted KYC." }
                        telnyxOrderId: { type: [string, "null"], description: Present once the number order has been placed (i.e. the requirement group was approved). Absent while still in identity review. }
                        monthlyCents: { type: integer, description: Per-country monthly price in cents ($2..$25). }
                        hostedByZernio: { type: boolean, description: "False for numbers you brought yourself (connected via Meta embedded signup) — they live on your own carrier, so SMS/Calls can't be enabled on them." }
                        profileId: { type: object }
                        provisionedAt: { type: string, format: date-time }
                        metaPreverifiedId: { type: string }
                        metaVerificationStatus: { type: string }
                        onfidoVerificationUrl: { type: [string, "null"], description: For regulated (Tier 3/4) numbers with an Onfido ID-verification step — the link to forward to the end user. Set once the order is placed; null otherwise. Poll this field after submitting KYC. }
                        endUserFirstName: { type: [string, "null"] }
                        endUserLastName: { type: [string, "null"] }
                        regulatoryDeclineReason: { type: [string, "null"], description: Reviewer rejection reason when status is regulatory_declined. }
                        createdAt: { type: string, format: date-time }
                  connected:
                    type: array
                    description: |
                      Connected (bring-your-own) WhatsApp numbers — your own WABA
                      numbers linked via Embedded Signup. Not provisioned or billed
                      by Zernio, so they are not in `numbers`; `accountId` is the
                      social-account id used by the messaging and inbox endpoints.
                      Included only on the default and `status=active` views.
                    items:
                      type: object
                      properties:
                        accountId: { type: string }
                        phoneNumber: { type: [string, "null"] }
                        displayName: { type: [string, "null"] }
                        profileId: { type: [string, "null"] }
                        connectedAt: { type: [string, "null"], format: date-time }
                  sandbox:
                    type: [object, "null"]
                    description: |
                      The shared WhatsApp sandbox (one Zernio-owned number, all users test
                      against it). Present when the sandbox is configured; null otherwise.
                      The `accountId` lets you address the sandbox in compose endpoints.
                      `template` is the only template a sandbox send is allowed to use.
                    properties:
                      phoneNumber: { type: string, example: "+12029087457" }
                      accountId: { type: [string, "null"] }
                      template:
                        type: object
                        properties:
                          name: { type: string, example: "sandbox_start" }
                          language: { type: string, example: "en" }
                      isSandbox: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/phone-numbers/{id}:
    get:
      operationId: getPhoneNumber
      tags: [Phone Numbers]
      summary: Get phone number
      description: |
        Retrieve the current status of a purchased phone number. Poll this to
        track Meta pre-verification (US sync path) and, for regulated (Tier 3/4)
        numbers, the async lifecycle: pending_regulatory → active (or
        regulatory_declined). When a regulated number has an Onfido ID step,
        `onfidoVerificationUrl` appears here once the order is placed — forward
        it to the end user. (Or subscribe to the whatsapp.number.* webhooks
        instead of polling.)
      security:
        - bearerAuth: []
      parameters:
        - name: id
          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, pending_regulatory, regulatory_declined, provisioning, verifying, active, suspended, releasing, released] }
                      country: { type: string }
                      metaPreverifiedId: { type: string }
                      metaVerificationStatus: { type: string }
                      onfidoVerificationUrl: { type: [string, "null"], description: For a regulated number with an Onfido ID step — the link to forward to the end user. Appears once the order is placed; null otherwise. }
                      endUserFirstName: { type: [string, "null"] }
                      endUserLastName: { type: [string, "null"] }
                      regulatoryDeclineReason: { type: [string, "null"], description: Reviewer rejection reason when status is regulatory_declined. }
                      provisionedAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: releasePhoneNumber
      tags: [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: id
          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' }

  /v1/phone-numbers/purchase:
    post:
      operationId: purchasePhoneNumber
      tags: [Phone Numbers]
      summary: Purchase phone number
      description: |
        Initiate purchasing a 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
                country:
                  type: string
                  description: >
                    ISO 3166-1 alpha-2 country for the number (default US).
                    International numbers require usage-based billing. Tier 3/4
                    countries return 202 { status: "kyc_required", kycUrl } —
                    the customer must complete KYC at that URL before the number
                    is ordered. See GET /v1/phone-numbers/countries.
                  default: US
                connectWhatsapp:
                  type: boolean
                  default: true
                  description: >
                    A phone number is the unit; WhatsApp is one optional feature.
                    Pass false to buy a STANDALONE number (Calls/SMS only):
                    provisioning skips the Meta pre-verify/OTP steps and the
                    number activates immediately. Omitted defaults to the
                    WhatsApp provisioning path. WhatsApp can be connected to a
                    standalone number later from the connect flow.
                wantsSms:
                  type: boolean
                  default: false
                  description: >
                    SMS capability is per-number, not per-country. Pass true to
                    provision from the SMS-capable inventory pool so the number
                    can actually text (see also GET /v1/phone-numbers/available
                    with sms=true, and smsAvailable on GET
                    /v1/phone-numbers/countries).
                purchaseIntentId:
                  type: string
                  maxLength: 100
                  description: >
                    Optional idempotency key. Send the same value when retrying
                    a purchase: if a number was already bought under this key,
                    the API returns { status: "already_purchased", numberId,
                    phoneNumber } instead of provisioning a second number.
                    Generate a fresh key for each genuinely new purchase.
                allowMultiple:
                  type: boolean
                  default: false
                  description: >
                    Any second purchase within 10 minutes of a previous one is
                    rejected with 409 code PURCHASE_VELOCITY as duplicate
                    protection. Pass true to confirm the additional purchase is
                    intentional (e.g. bulk provisioning).
            example:
              profileId: "507f1f77bcf86cd799439011"
              country: "DE"
      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 }
                  - type: object
                    description: >
                      A number was already purchased under the supplied
                      purchaseIntentId; no new number was provisioned.
                    properties:
                      status: { type: string, enum: [already_purchased] }
                      numberId: { type: string }
                      phoneNumber: { type: string }
        '400': { description: "Plan limit reached, profileId required, or country not available" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: A paid plan is required }
        '409':
          description: >
            Duplicate-purchase protection: another number was purchased for
            this user within the last 10 minutes. Retry with allowMultiple:
            true to confirm the additional purchase is intentional.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, enum: [PURCHASE_VELOCITY] }
        '202':
          description: Country requires end-user KYC before the number can be ordered.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [kyc_required] }
                  country: { type: string }
                  kycUrl: { type: string }
        '402': { description: 'Payment method required (Metronome user with no card on file). Response body carries code: PAYMENT_REQUIRED; add a card, then retry.' }
        '422': { description: 'International numbers require usage-based billing (legacy Stripe users are US-only). Response body code: USAGE_BILLING_REQUIRED.' }

  /v1/phone-numbers/countries:
    get:
      operationId: listPhoneNumberCountries
      tags: [Phone Numbers]
      summary: List offerable number countries
      description: |
        The phone number countries available to purchase, each with its flat
        monthly price (cents), regulatory tier, whether it needs end-user KYC
        (Tier 3/4), and per-feature availability (PSTN calls, WhatsApp, SMS,
        and WhatsApp Business Calling outbound). Drives the country picker.
        Tier-4 countries appear only when enabled.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Offerable countries, cheapest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  countries:
                    type: array
                    items:
                      type: object
                      properties:
                        code: { type: string, description: ISO 3166-1 alpha-2 }
                        tier: { type: integer, enum: [1, 2, 3, 4] }
                        monthlyCents: { type: integer }
                        needsKyc: { type: boolean }
                        callsAvailable: { type: boolean, description: "Regular phone (PSTN) calling on the number, inbound + outbound. Available on every offerable country." }
                        whatsappAvailable: { type: boolean, description: WhatsApp can be enabled on numbers from this country. }
                        smsAvailable: { type: boolean, description: Whether this country's number type can do SMS. Use it to filter the picker when the buyer wants SMS (pair with `wantsSms` on purchase). }
                        outboundCallingAvailable: { type: boolean, description: "WhatsApp Business Calling (BIC) outbound availability, a Meta feature blocked in some countries. NOT the PSTN Calls feature (`callsAvailable`)." }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/phone-numbers/available:
    get:
      operationId: searchAvailablePhoneNumbers
      tags: [Phone Numbers]
      summary: Search available numbers
      description: |
        Search the provider's inventory for numbers available to purchase in a
        country (default US). Optional filters narrow the results. The country
        must be offerable (see GET /v1/phone-numbers/countries). Voice
        capability is always required; pass `sms=true` to only see numbers that
        can also text (SMS support is per-number, not per-country).
      security:
        - bearerAuth: []
      parameters:
        - { name: country, in: query, required: false, schema: { type: string, default: US } }
        - { name: type, in: query, required: false, description: "Number type; defaults to the country's WhatsApp-safe type", schema: { type: string } }
        - { name: prefix, in: query, required: false, description: Area code, schema: { type: string } }
        - { name: locality, in: query, required: false, description: City, schema: { type: string } }
        - { name: contains, in: query, required: false, description: Pattern to match within the number, schema: { type: string } }
        - { name: sms, in: query, required: false, schema: { type: boolean }, description: "true narrows the pool to SMS-capable numbers. Each result still carries its full `features` list for per-number capability badging." }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Available numbers.
          content:
            application/json:
              schema:
                type: object
                properties:
                  country: { type: string }
                  numberType: { type: string }
                  requireSms: { type: boolean, description: Echo of the `sms` filter applied to this search. }
                  numbers:
                    type: array
                    items:
                      type: object
                      properties:
                        phoneNumber: { type: string }
                        features: { type: array, items: { type: string }, description: "Provider capability list for this number (e.g. voice, sms, mms)." }
        '400': { description: Country not available }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/phone-numbers/availability:
    get:
      operationId: checkPhoneNumberAvailability
      tags: [Phone Numbers]
      summary: Check country availability
      description: |
        Pre-purchase check, so you can warn BEFORE a customer invests in KYC
        (regulated review is async, 1-3 days). Tells you whether we have
        deliverable inventory, and what address the customer needs:
          - `addressConstraint: geo`  → the registered address MUST be in one of
            the returned `areas` (the only place we have stock). A different-area
            address passes pre-approval but the number can never be assigned.
          - `addressConstraint: country` → any in-country address works.
          - `addressConstraint: none` → field-only / instant country, no address.
        Call this before starting the KYC form for regulated countries.
      security:
        - bearerAuth: []
      parameters:
        - { name: country, in: query, required: true, schema: { type: string }, description: ISO-2 country code. }
      responses:
        '200':
          description: Availability + address constraint.
          content:
            application/json:
              schema:
                type: object
                properties:
                  country: { type: string }
                  numberType: { type: string }
                  available: { type: boolean, description: Whether deliverable voice inventory exists right now. }
                  addressConstraint: { type: string, enum: [geo, country, none] }
                  areas:
                    type: array
                    description: For `geo` only — the area(s) the registered address must be in.
                    items: { type: string }
        '400': { description: Country not offerable }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/phone-numbers:
    get:
      operationId: getWhatsAppPhoneNumbers
      deprecated: true
      tags: [WhatsApp Phone Numbers]
      summary: List phone numbers
      description: |
        Deprecated alias of `/v1/phone-numbers`; same contract. New
        integrations should use that path.

        List all WhatsApp phone numbers purchased by the authenticated user.
        By default, released numbers are excluded. Connected (bring-your-own)
        numbers are returned in the separate `connected` array — they are not
        billed and have no provisioning lifecycle.
      security:
        - bearerAuth: []
      parameters:
        - name: status
          in: query
          required: false
          description: |
            Filter by status (by default excludes released numbers). NOTE:
            `status=pending_regulatory` returns the "provisioning" view — numbers
            still in review PLUS recently-declined (last 30 days) ones, so a
            failed registration surfaces (with `regulatoryDeclineReason`) instead
            of silently disappearing. Declined numbers can be re-submitted via
            POST /v1/whatsapp/phone-numbers/{id}/remediate. `verifying` is the
            short-lived state after the number is provisioned on our side while
            WhatsApp confirms the activation code; the number is not billed until
            it reaches `active`.
          schema:
            type: string
            enum: [provisioning, verifying, pending_payment, pending_regulatory, regulatory_declined, 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, pending_regulatory, regulatory_declined, provisioning, verifying, active, suspended, releasing, released] }
                        registrantName: { type: [string, "null"], description: "For regulated numbers, who it's registered for (company or person) — set from the submitted KYC." }
                        telnyxOrderId: { type: [string, "null"], description: Present once the number order has been placed (i.e. the requirement group was approved). Absent while still in identity review. }
                        monthlyCents: { type: integer, description: Per-country monthly price in cents ($2..$25). }
                        hostedByZernio: { type: boolean, description: "False for numbers you brought yourself (connected via Meta embedded signup) — they live on your own carrier, so SMS/Calls can't be enabled on them." }
                        profileId: { type: object }
                        provisionedAt: { type: string, format: date-time }
                        metaPreverifiedId: { type: string }
                        metaVerificationStatus: { type: string }
                        onfidoVerificationUrl: { type: [string, "null"], description: For regulated (Tier 3/4) numbers with an Onfido ID-verification step — the link to forward to the end user. Set once the order is placed; null otherwise. Poll this field after submitting KYC. }
                        endUserFirstName: { type: [string, "null"] }
                        endUserLastName: { type: [string, "null"] }
                        regulatoryDeclineReason: { type: [string, "null"], description: Reviewer rejection reason when status is regulatory_declined. }
                        createdAt: { type: string, format: date-time }
                  connected:
                    type: array
                    description: |
                      Connected (bring-your-own) WhatsApp numbers — your own WABA
                      numbers linked via Embedded Signup. Not provisioned or billed
                      by Zernio, so they are not in `numbers`; `accountId` is the
                      social-account id used by the messaging and inbox endpoints.
                      Included only on the default and `status=active` views.
                    items:
                      type: object
                      properties:
                        accountId: { type: string }
                        phoneNumber: { type: [string, "null"] }
                        displayName: { type: [string, "null"] }
                        profileId: { type: [string, "null"] }
                        connectedAt: { type: [string, "null"], format: date-time }
                  sandbox:
                    type: [object, "null"]
                    description: |
                      The shared WhatsApp sandbox (one Zernio-owned number, all users test
                      against it). Present when the sandbox is configured; null otherwise.
                      The `accountId` lets you address the sandbox in compose endpoints.
                      `template` is the only template a sandbox send is allowed to use.
                    properties:
                      phoneNumber: { type: string, example: "+12029087457" }
                      accountId: { type: [string, "null"] }
                      template:
                        type: object
                        properties:
                          name: { type: string, example: "sandbox_start" }
                          language: { type: string, example: "en" }
                      isSandbox: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/phone-numbers/purchase:
    post:
      operationId: purchaseWhatsAppPhoneNumber
      deprecated: true
      tags: [WhatsApp Phone Numbers]
      summary: Purchase phone number
      description: |
        Deprecated alias of `/v1/phone-numbers/purchase`; same contract. New
        integrations should use that path.

        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
                country:
                  type: string
                  description: >
                    ISO 3166-1 alpha-2 country for the number (default US).
                    International numbers require usage-based billing. Tier 3/4
                    countries return 202 { status: "kyc_required", kycUrl } —
                    the customer must complete KYC at that URL before the number
                    is ordered. See GET /v1/whatsapp/phone-numbers/countries.
                  default: US
                purchaseIntentId:
                  type: string
                  maxLength: 100
                  description: >
                    Optional idempotency key. Send the same value when retrying
                    a purchase: if a number was already bought under this key,
                    the API returns { status: "already_purchased", numberId,
                    phoneNumber } instead of provisioning a second number.
                    Generate a fresh key for each genuinely new purchase.
                allowMultiple:
                  type: boolean
                  default: false
                  description: >
                    Any second purchase within 10 minutes of a previous one is
                    rejected with 409 code PURCHASE_VELOCITY as duplicate
                    protection. Pass true to confirm the additional purchase is
                    intentional (e.g. bulk provisioning).
            example:
              profileId: "507f1f77bcf86cd799439011"
              country: "DE"
      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 }
                  - type: object
                    description: >
                      A number was already purchased under the supplied
                      purchaseIntentId; no new number was provisioned.
                    properties:
                      status: { type: string, enum: [already_purchased] }
                      numberId: { type: string }
                      phoneNumber: { type: string }
        '400': { description: "Plan limit reached, profileId required, or country not available" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: A paid plan is required }
        '409':
          description: >
            Duplicate-purchase protection: another number was purchased for
            this user within the last 10 minutes. Retry with allowMultiple:
            true to confirm the additional purchase is intentional.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, enum: [PURCHASE_VELOCITY] }
        '202':
          description: Country requires end-user KYC before the number can be ordered.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [kyc_required] }
                  country: { type: string }
                  kycUrl: { type: string }
        '402': { description: 'Payment method required (Metronome user with no card on file). Response body carries code: PAYMENT_REQUIRED; add a card, then retry.' }
        '422': { description: 'International numbers require usage-based billing (legacy Stripe users are US-only). Response body code: USAGE_BILLING_REQUIRED.' }

  /v1/whatsapp/phone-numbers/countries:
    get:
      operationId: listWhatsAppNumberCountries
      deprecated: true
      tags: [WhatsApp Phone Numbers]
      summary: List offerable number countries
      description: |
        Deprecated alias of `/v1/phone-numbers/countries`; same contract. New
        integrations should use that path.

        The WhatsApp number countries available to purchase, each with its flat
        monthly price (cents), regulatory tier, whether it needs end-user KYC
        (Tier 3/4), and whether outbound calling is available (not BIC-blocked).
        Drives the country picker. Tier-4 countries appear only when enabled.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Offerable countries, cheapest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  countries:
                    type: array
                    items:
                      type: object
                      properties:
                        code: { type: string, description: ISO 3166-1 alpha-2 }
                        tier: { type: integer, enum: [1, 2, 3, 4] }
                        monthlyCents: { type: integer }
                        needsKyc: { type: boolean }
                        outboundCallingAvailable: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/phone-numbers/available:
    get:
      operationId: searchAvailableWhatsAppNumbers
      deprecated: true
      tags: [WhatsApp Phone Numbers]
      summary: Search available numbers
      description: |
        Deprecated alias of `/v1/phone-numbers/available`; same contract. New
        integrations should use that path.

        Search the provider's inventory for numbers available to purchase in a
        country (default US). Optional filters narrow the results. The country
        must be offerable (see GET /v1/whatsapp/phone-numbers/countries).
      security:
        - bearerAuth: []
      parameters:
        - { name: country, in: query, required: false, schema: { type: string, default: US } }
        - { name: type, in: query, required: false, description: "Number type; defaults to the country's WhatsApp-safe type", schema: { type: string } }
        - { name: prefix, in: query, required: false, description: Area code, schema: { type: string } }
        - { name: locality, in: query, required: false, description: City, schema: { type: string } }
        - { name: contains, in: query, required: false, description: Pattern to match within the number, schema: { type: string } }
        - { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
      responses:
        '200':
          description: Available numbers.
          content:
            application/json:
              schema:
                type: object
                properties:
                  country: { type: string }
                  numberType: { type: string }
                  numbers:
                    type: array
                    items:
                      type: object
                      properties:
                        phoneNumber: { type: string }
        '400': { description: Country not available }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/phone-numbers/availability:
    get:
      operationId: checkWhatsAppNumberAvailability
      deprecated: true
      tags: [WhatsApp Phone Numbers]
      summary: Check country availability
      description: |
        Deprecated alias of `/v1/phone-numbers/availability`; same contract. New
        integrations should use that path.

        Pre-purchase check, so you can warn BEFORE a customer invests in KYC
        (regulated review is async, 1-3 days). Tells you whether we have
        deliverable inventory, and what address the customer needs:
          - `addressConstraint: geo`  → the registered address MUST be in one of
            the returned `areas` (the only place we have stock). A different-area
            address passes pre-approval but the number can never be assigned.
          - `addressConstraint: country` → any in-country address works.
          - `addressConstraint: none` → field-only / instant country, no address.
        Call this before starting the KYC form for regulated countries.
      security:
        - bearerAuth: []
      parameters:
        - { name: country, in: query, required: true, schema: { type: string }, description: ISO-2 country code. }
      responses:
        '200':
          description: Availability + address constraint.
          content:
            application/json:
              schema:
                type: object
                properties:
                  country: { type: string }
                  numberType: { type: string }
                  available: { type: boolean, description: Whether deliverable voice inventory exists right now. }
                  addressConstraint: { type: string, enum: [geo, country, none] }
                  areas:
                    type: array
                    description: For `geo` only — the area(s) the registered address must be in.
                    items: { type: string }
        '400': { description: Country not offerable }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/phone-numbers/kyc:
    get:
      operationId: getPhoneNumberKycForm
      tags: [Phone Numbers]
      summary: Get KYC form spec
      description: |
        For a Tier 3/4 country, the fields the end customer must provide (Telnyx
        regulatory requirements) before a number can be ordered: text, date,
        address, or file (document) per requirement.
      security:
        - bearerAuth: []
      parameters:
        - { name: country, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: The KYC form spec.
          content:
            application/json:
              schema:
                type: object
                properties:
                  country: { type: string }
                  numberType: { type: string }
                  fields:
                    type: array
                    items:
                      type: object
                      properties:
                        requirementId: { type: string }
                        label: { type: string }
                        kind: { type: string, enum: [text, date, address, file, action], description: '"action" = an out-of-band verification (e.g. Onfido); not filled here, fulfilled after the order via a link.' }
                        description: { type: [string, "null"], description: Plain-English explanation of what to provide. }
                        example: { type: [string, "null"], description: Concrete example value. }
                        localTo: { type: [string, "null"], description: ISO country the value must be local to }
                  reusable:
                    type: [object, "null"]
                    description: Present when this account already has an approved verification for the country that can be reused (skip the form). `fromPhoneNumber`/`details` mirror the newest option; `options` lists ALL approved verifications (agencies hold one per end client) — pass the chosen option's `fromPhoneNumber` as `reuseFrom` on POST.
                    properties:
                      available: { type: boolean }
                      fromPhoneNumber: { type: string }
                      details:
                        type: array
                        description: Human-readable summary of the verification on file (field labels + values, plus the address as one line). Best-effort — may be empty if the provider lookup fails.
                        items:
                          type: object
                          properties:
                            label: { type: string }
                            value: { type: string }
                      options:
                        type: array
                        description: One entry per distinct approved verification, newest first.
                        items:
                          type: object
                          properties:
                            fromPhoneNumber: { type: string }
                            details:
                              type: array
                              items:
                                type: object
                                properties:
                                  label: { type: string }
                                  value: { type: string }
        '400': { description: Country not available }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: submitPhoneNumberKyc
      tags: [Phone Numbers]
      summary: Submit KYC
      description: |
        Submit the end customer's KYC (textual values, uploaded documents,
        address) for a Tier 3/4 country. Documents are streamed straight to the
        number provider and are not stored by Zernio. Builds + submits a
        regulatory requirement group and claims a pending_regulatory slot; the
        number is ordered + activated once the provider approves (asynchronous).
        A customer may hold several same-country numbers in review at once; a
        double-submit of the SAME attempt is deduped via `submissionId`.

        For an ID-card document requirement, carriers commonly require BOTH sides:
        combine the front and back into a single file before uploading (the
        dashboard does this automatically). A one-sided ID is a common decline
        reason; fix it via POST /v1/phone-numbers/{id}/remediate.

        Before submitting, call GET /v1/phone-numbers/availability to
        check the country has deliverable inventory and, for geographic-match
        countries, which area the address must be in — otherwise the submission
        can pass review yet never be assignable a number.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, country]
              properties:
                profileId: { type: string }
                country: { type: string }
                submissionId: { type: string, description: Idempotency token for this submission attempt. A retry/double-submit with the same token returns the same number; omit and each call creates a new number. }
                quantity: { type: integer, minimum: 1, maximum: 5, default: 1, description: 'Provision several same-country numbers from one submission (1-5). The single verification covers all of them; each number is billed only when it activates. Numbers that fail to order are skipped (best-effort).' }
                reuse: { type: boolean, description: Reuse a prior approved verification for this country (skips document/field collection; places the order immediately). }
                reuseFrom: { type: string, description: 'Which approved verification to reuse when several exist: the phone number it was originally approved for (GET reusable.options[].fromPhoneNumber). Omitted = newest. No match = 409.' }
                endUserFirstName: { type: string, description: End user's legal first name. Required when the country has an action/ID-verification (Onfido) requirement. }
                endUserLastName: { type: string, description: End user's legal last name. Same condition as endUserFirstName. }
                values:
                  type: object
                  additionalProperties: { type: string }
                  description: requirementId → textual value
                documents:
                  type: array
                  description: One per document requirement. Each is EITHER inline base64 OR a `documentId` returned by POST /v1/phone-numbers/kyc/upload-document (use the upload endpoint for large files to stay under the request-size limit).
                  items:
                    oneOf:
                      - type: object
                        required: [requirementId, filename, base64]
                        properties:
                          requirementId: { type: string }
                          filename: { type: string }
                          base64: { type: string }
                      - type: object
                        required: [requirementId, documentId]
                        properties:
                          requirementId: { type: string }
                          documentId: { type: string, description: Id from POST /v1/phone-numbers/kyc/upload-document. }
                address:
                  type: object
                  properties:
                    requirementId: { type: string }
                    country_code: { type: string }
                    business_name: { type: string }
                    first_name: { type: string }
                    last_name: { type: string }
                    street_address: { type: string }
                    locality: { type: string }
                    administrative_area: { type: string }
                    postal_code: { type: string }
      responses:
        '200':
          description: KYC submitted (or already submitted); number pending review.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [kyc_submitted, kyc_reused, kyc_already_submitted] }
                  phoneNumber:
                    type: object
                    description: The first/primary number, kept at the top level for backward compatibility. See `numbers` for the full set when `quantity` > 1.
                    properties:
                      id: { type: string }
                      status: { type: string }
                      country: { type: string }
                  numbers:
                    type: array
                    description: Every number provisioned from this submission. Length equals the requested `quantity` on full success (fewer if some orders failed; best-effort). The first element mirrors `phoneNumber`.
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        status: { type: string }
                        phoneNumber: { type: string }
                        country: { type: string }
        '400': { description: "Validation error (e.g. address not in-country, file too large)" }
        '409': { description: reuse requested but no prior approved verification exists for this country }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/phone-numbers/kyc/upload-document:
    post:
      operationId: uploadPhoneNumberKycDocument
      tags: [Phone Numbers]
      summary: Upload a KYC document
      description: |
        Upload ONE document and get back its provider document id, to reference
        from POST /v1/phone-numbers/kyc via `documents[].documentId`.
        Send the RAW file bytes as the request body (not base64); put the filename
        in the `X-Filename` header. Uploading documents one-per-request keeps each
        request under the ~4.5MB body limit. The document streams straight to the
        number provider and is not stored by Zernio.
      security:
        - bearerAuth: []
      parameters:
        - { name: X-Filename, in: header, required: true, schema: { type: string }, description: URL-encoded original filename. }
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema: { type: string, format: binary }
      responses:
        '200':
          description: Document uploaded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  documentId: { type: string, description: "Reference this id in the KYC submit's documents[].documentId." }
        '400': { description: "Missing X-Filename, empty body, or file too large (over 20MB)." }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/phone-numbers/kyc/validate-address:
    post:
      operationId: validatePhoneNumberKycAddress
      tags: [Phone Numbers]
      summary: Pre-validate KYC address
      description: |
        Optional early check for the address step of a Tier 4 (end-user identity)
        registration: validates a postal address for deliverability BEFORE the full
        KYC submit, so it can be corrected before any documents are uploaded. The
        full submit (POST /v1/phone-numbers/kyc) re-validates the address,
        so this call is purely a fast feedback path and skipping it is safe. Only
        the postal address is sent (no documents, no gov-ID fields). A region
        (`administrative_area`) is required by the validator; when it is omitted the
        pre-check is skipped and `{ ok: true, skipped: true }` is returned (the
        final submit still validates).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [country, street_address, locality, postal_code]
              properties:
                country: { type: string, minLength: 2, maxLength: 2, description: ISO 3166-1 alpha-2 country code. }
                street_address: { type: string }
                locality: { type: string, description: City / town. }
                administrative_area: { type: string, description: "State / province / region. When omitted, the pre-check is skipped (the final submit still validates)." }
                postal_code: { type: string }
      responses:
        '200':
          description: Address is deliverable, or the pre-check was skipped (no region supplied).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  skipped: { type: boolean, description: "true when no `administrative_area` was supplied, so no pre-check ran." }
        '400':
          description: |
            The country isn't offered, or the address could not be verified. When the
            provider returned usable corrections, `details.addressSuggestions` carries
            them per field for a one-click "apply suggestion" card. (Flat error
            envelope: `error` is the human message; `code`/`param`/`details` are
            top-level siblings.)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, description: Human-readable message. }
                  type: { type: string }
                  code: { type: string, example: INVALID_FIELD_VALUE }
                  param: { type: string, example: address }
                  details:
                    type: object
                    properties:
                      addressSuggestions:
                        type: array
                        items:
                          type: object
                          properties:
                            field: { type: string, example: administrative_area }
                            label: { type: string, example: State / Province }
                            value: { type: string, example: Dublin }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/phone-numbers/kyc/share:
    post:
      operationId: createPhoneNumberKycLink
      tags: [Phone Numbers]
      summary: Create a hosted KYC link
      description: |
        Create a single-use, 7-day hosted KYC link that your end customer
        completes WITHOUT a Zernio login — useful when the person who holds the
        ID and address is not your team. They fill the regulated verification on
        a Zernio-hosted page; the number provisions under YOUR account once they
        submit. Only regulated (KYC) countries are valid: a country that does not
        require KYC returns 400.

        White-label the page with `branding` (your company name, logo, brand
        color). Supply `redirect_url` to send the end customer back to your own
        site after a successful submit (completion params are appended — see
        below). Listen for the `whatsapp.number.kyc_submitted` webhook to react
        when the form is completed.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, country]
              properties:
                profileId: { type: string }
                country: { type: string, minLength: 2, maxLength: 2, description: ISO 3166-1 alpha-2 country code (must be a regulated/KYC country). }
                branding:
                  type: object
                  description: Optional white-label of the hosted page the end customer sees.
                  properties:
                    companyName: { type: string, maxLength: 60, description: 'Your company name, shown on the hosted page.' }
                    logoUrl: { type: string, format: uri, description: Logo shown above the form. }
                    brandColor: { type: string, pattern: '^#[0-9a-fA-F]{6}$', description: 'Hex color (e.g. #1a73e8) used as a brand accent on the page.' }
                redirect_url:
                  type: string
                  format: uri
                  description: |
                    Where to send the end customer's browser after a successful
                    submit. On completion Zernio appends `kyc=submitted` and
                    `country=<ISO-2>` as query params. When omitted, the hosted
                    page shows a built-in confirmation screen instead.
      responses:
        '200':
          description: Hosted KYC link created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, description: The hosted link to send your end customer. }
                  token: { type: string }
                  expiresAt: { type: string, format: date-time }
        '400': { description: Country does not require KYC (not a regulated country). }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/phone-numbers/port-in:
    post:
      operationId: createPhoneNumberPortIn
      tags: [Phone Numbers]
      summary: Port numbers in
      description: |
        Submit a port-in for one or more existing numbers from another carrier.
        Creates the carrier order(s), attaches the end-user (current account)
        info plus the LOA and invoice documents, and submits to the losing
        carrier. The transfer PIN is forwarded to the carrier and never stored.
        Ported numbers arrive voice-ready (and SMS-ready where the order
        supports messaging).

        Run the portability check (POST /v1/phone-numbers/port-in/check) and
        upload the two documents (POST /v1/phone-numbers/port-in/documents)
        first. The carrier may split the numbers into several orders (by
        country, number type, losing carrier); `orders` carries per-order
        results, and a partial failure still returns 201 with the failed
        orders' `error` set (they stay as cancellable drafts).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phoneNumbers, endUser, loaDocumentId, invoiceDocumentId]
              properties:
                phoneNumbers:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items: { type: string }
                  description: E.164 numbers to port in.
                endUser:
                  type: object
                  description: End-user / current-carrier account info that authorizes the port.
                  required: [entityName, authPersonName, streetAddress, locality, administrativeArea, postalCode, countryCode]
                  properties:
                    entityName: { type: string }
                    authPersonName: { type: string }
                    billingPhoneNumber: { type: string }
                    accountNumber: { type: string }
                    pinPasscode: { type: string, description: "Transfer PIN. Forwarded to the carrier, never stored." }
                    streetAddress: { type: string }
                    extendedAddress: { type: string }
                    locality: { type: string }
                    administrativeArea: { type: string }
                    postalCode: { type: string }
                    countryCode: { type: string, minLength: 2, maxLength: 2 }
                loaDocumentId: { type: string, description: Document id from POST /v1/phone-numbers/port-in/documents (kind=loa). }
                invoiceDocumentId: { type: string, description: Document id from POST /v1/phone-numbers/port-in/documents (kind=invoice). }
                focDatetimeRequested: { type: string, format: date-time, description: Requested port date; the carrier confirms the actual FOC later. }
                customerReference: { type: string, maxLength: 100 }
      responses:
        '201':
          description: Port submitted. Top-level fields mirror the first successfully submitted order; per-order truth (including failures) is in `orders`.
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, description: Porting order ID. }
                  telnyxPortingOrderId: { type: string }
                  status: { type: string, enum: [draft, pending, foc_confirmed, ported, exception, cancelled] }
                  phoneNumbers: { type: array, items: { type: string } }
                  orders:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        telnyxPortingOrderId: { type: string }
                        status: { type: string }
                        phoneNumbers: { type: array, items: { type: string } }
                        error: { type: string, description: Present when this split order failed to submit (it stays as a cancellable draft). }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { description: "A number is already provisioned, or already in an in-flight port" }
        '422': { description: "A number is not portable (reason included), or every split order failed to submit" }
    get:
      operationId: listPhoneNumberPortIns
      tags: [Phone Numbers]
      summary: List port-in orders
      description: |
        Your porting orders, newest first (max 50). Poll this for port progress:
        pending, confirmed FOC date, exception reason, or ported.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Porting orders
          content:
            application/json:
              schema:
                type: object
                properties:
                  orders:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        status: { type: string, enum: [draft, pending, foc_confirmed, ported, exception, cancelled] }
                        telnyxStatusValue: { type: [string, "null"], description: Raw carrier status string. }
                        phoneNumbers: { type: array, items: { type: string } }
                        fastPortEligible: { type: [boolean, "null"] }
                        focDatetimeRequested: { type: [string, "null"], format: date-time }
                        focDatetimeActual: { type: [string, "null"], format: date-time }
                        declineReason: { type: [string, "null"] }
                        submittedAt: { type: [string, "null"], format: date-time }
                        portedAt: { type: [string, "null"], format: date-time }
                        createdAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/phone-numbers/port-in/check:
    post:
      operationId: checkPhoneNumberPortability
      tags: [Phone Numbers]
      summary: Check portability
      description: |
        Pre-flight portability check: whether each number can be ported in and
        whether it qualifies for FastPort, BEFORE the user commits to a port
        order (LOA, invoice, service address). Read-only; creates no order and
        bills nothing.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phoneNumbers]
              properties:
                phoneNumbers:
                  type: array
                  minItems: 1
                  maxItems: 50
                  items: { type: string }
                  description: "E.164 numbers to check, e.g. +13035550000."
      responses:
        '200':
          description: Per-number portability.
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        phoneNumber: { type: string }
                        portable: { type: boolean }
                        fastPortable: { type: boolean, description: Qualifies for the carrier's accelerated FastPort lane. }
                        notPortableReason: { type: [string, "null"], description: Carrier reason when not portable; null when portable. }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/phone-numbers/port-in/documents:
    post:
      operationId: uploadPhoneNumberPortInDocument
      tags: [Phone Numbers]
      summary: Upload a porting document
      description: |
        Upload ONE porting document (the signed LOA or a recent carrier invoice)
        and get back its `documentId`, which the port-in create request takes as
        `loaDocumentId` / `invoiceDocumentId`. PDF, JPEG, or PNG, 10MB max.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary, description: "The document (PDF/JPEG/PNG, 10MB max)." }
                kind: { type: string, enum: [loa, invoice], description: Informational; used for the stored filename. }
      responses:
        '200':
          description: Document uploaded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  documentId: { type: string }
        '400': { description: "Missing file, file too large, or unsupported type" }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/phone-numbers/port-in/{id}:
    delete:
      operationId: cancelPhoneNumberPortIn
      tags: [Phone Numbers]
      summary: Cancel a port-in
      description: |
        Cancel an in-flight port (wrong number, staying with the old carrier).
        Only orders that haven't ported can be cancelled; a completed port is a
        normal number release instead. The carrier may report `cancel-pending`
        briefly while the losing carrier acknowledges; it settles to
        `cancelled`.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: Porting order ID (from the port-in list). }
      responses:
        '200':
          description: Cancel accepted (idempotent when already cancelled).
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string }
                  status: { type: string, enum: [draft, pending, foc_confirmed, ported, exception, cancelled] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Porting order not found }
        '409': { description: "Port already completed (release the number instead), or the carrier rejected the cancel (reason included)" }

  /v1/phone-numbers/kyc/review-packet:
    post:
      operationId: reviewPhoneNumberKycPacket
      tags: [Phone Numbers]
      summary: Pre-review a KYC packet
      description: |
        Advisory dry-run of a regulated-KYC packet before submitting: reviews
        the exact documents the regulator will see (referenced by the ids from
        POST /v1/phone-numbers/kyc/upload-document) against the declared values
        and address, and returns plain-language advisories for likely decline
        reasons (wrong document type, mismatched address, one-sided ID scans).
        Non-blocking: advisories are warnings, submitting anyway is always
        allowed, and any review failure degrades to an empty list.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [country, numberType, docs]
              properties:
                country: { type: string, minLength: 2, maxLength: 2 }
                numberType: { type: string }
                values: { type: object, additionalProperties: { type: string }, description: requirementId to declared textual value. }
                address: { type: object, additionalProperties: { type: string }, description: "Declared address (street_address, locality, ...), so a mismatched proof-of-address can be flagged." }
                docs:
                  type: array
                  minItems: 1
                  maxItems: 4
                  items:
                    type: object
                    required: [requirementId, documentId]
                    properties:
                      requirementId: { type: string }
                      documentId: { type: string, description: Id from POST /v1/phone-numbers/kyc/upload-document. }
      responses:
        '200':
          description: Advisories (empty when the packet looks fine or the review was unavailable).
          content:
            application/json:
              schema:
                type: object
                properties:
                  advisories:
                    type: array
                    items:
                      type: object
                      properties:
                        requirementId: { type: string }
                        concern: { type: string, description: One short plain-language concern about that requirement's document. }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/phone-numbers/{id}/remediate:
    get:
      operationId: getPhoneNumberRemediation
      tags: [Phone Numbers]
      summary: Get declined requirements
      description: |
        For a number in `regulatory_declined`, returns ONLY the requirements the
        reviewer flagged declined, as a form spec (same shape as the KYC form GET).
        The customer fixes just those — Telnyx supports correcting a declined
        requirement group and re-submitting it (no new number/group). Falls back
        to the full spec if the provider exposes no per-requirement flags.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: Phone number record ID. }
      responses:
        '200':
          description: The declined requirements to fix.
          content:
            application/json:
              schema:
                type: object
                properties:
                  country: { type: string }
                  numberType: { type: string }
                  declineReason: { type: [string, "null"] }
                  fields: { type: array, items: { type: object }, description: Same field shape as GET /v1/phone-numbers/kyc. }
        '400': { description: Number is not awaiting remediation }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Number not found }
    post:
      operationId: remediatePhoneNumber
      tags: [Phone Numbers]
      summary: Resubmit a declined number
      description: |
        Submit corrected values/documents for the declined requirement(s). We
        PATCH them onto the SAME requirement group and re-submit it for approval;
        the number goes `regulatory_declined` → `pending_regulatory`. No new
        number and no new billing. Body shape matches the KYC submit (values /
        documents / address) — send only the corrected fields.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                values: { type: object, additionalProperties: { type: string } }
                documents:
                  type: array
                  items:
                    oneOf:
                      - { type: object, required: [requirementId, filename, base64], properties: { requirementId: { type: string }, filename: { type: string }, base64: { type: string } } }
                      - { type: object, required: [requirementId, documentId], properties: { requirementId: { type: string }, documentId: { type: string } } }
                address: { type: object, description: Same shape as the KYC submit address. }
      responses:
        '200':
          description: Re-submitted for approval.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: resubmitted }
                  phoneNumber: { type: object, properties: { id: { type: string }, status: { type: string } } }
        '400': { description: Number is not awaiting remediation / nothing to remediate }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Number not found }

  /v1/whatsapp/phone-numbers/kyc:
    get:
      operationId: getWhatsAppNumberKycForm
      deprecated: true
      tags: [WhatsApp Phone Numbers]
      summary: Get KYC form spec
      description: |
        Deprecated alias of `/v1/phone-numbers/kyc`; same contract. New
        integrations should use that path.

        For a Tier 3/4 country, the fields the end customer must provide (Telnyx
        regulatory requirements) before a number can be ordered: text, date,
        address, or file (document) per requirement.
      security:
        - bearerAuth: []
      parameters:
        - { name: country, in: query, required: true, schema: { type: string } }
        - { name: profileId, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: The KYC form spec.
          content:
            application/json:
              schema:
                type: object
                properties:
                  country: { type: string }
                  numberType: { type: string }
                  fields:
                    type: array
                    items:
                      type: object
                      properties:
                        requirementId: { type: string }
                        label: { type: string }
                        kind: { type: string, enum: [text, date, address, file, action], description: '"action" = an out-of-band verification (e.g. Onfido); not filled here, fulfilled after the order via a link.' }
                        description: { type: [string, "null"], description: Plain-English explanation of what to provide. }
                        example: { type: [string, "null"], description: Concrete example value. }
                        localTo: { type: [string, "null"], description: ISO country the value must be local to }
                        audience: { type: [string, "null"], enum: [business, individual, null], description: 'When set, the requirement applies ONLY to this end-user type — provide it for that type and OMIT it for the other (e.g. Brazil: "Cartão CNPJ" is business-only, "CPF" and "ID/Passport Copy" are personal-only). Submitting both sets makes the regulator ask whether the number is for personal or business use and stalls the review. Pass `entityType` on POST so the server drops the inapplicable set.' }
                  reusable:
                    type: [object, "null"]
                    description: Present when this account already has an approved verification for the country that can be reused (skip the form). `fromPhoneNumber`/`details` mirror the newest option; `options` lists ALL approved verifications (agencies hold one per end client) — pass the chosen option's `fromPhoneNumber` as `reuseFrom` on POST.
                    properties:
                      available: { type: boolean }
                      fromPhoneNumber: { type: string }
                      details:
                        type: array
                        description: Human-readable summary of the verification on file (field labels + values, plus the address as one line). Best-effort — may be empty if the provider lookup fails.
                        items:
                          type: object
                          properties:
                            label: { type: string }
                            value: { type: string }
                      options:
                        type: array
                        description: One entry per distinct approved verification, newest first.
                        items:
                          type: object
                          properties:
                            fromPhoneNumber: { type: string }
                            details:
                              type: array
                              items:
                                type: object
                                properties:
                                  label: { type: string }
                                  value: { type: string }
        '400': { description: Country not available }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      operationId: submitWhatsAppNumberKyc
      deprecated: true
      tags: [WhatsApp Phone Numbers]
      summary: Submit KYC
      description: |
        Deprecated alias of `/v1/phone-numbers/kyc`; same contract. New
        integrations should use that path.

        Submit the end customer's KYC (textual values, uploaded documents,
        address) for a Tier 3/4 country. Documents are streamed straight to the
        number provider and are not stored by Zernio. Builds + submits a
        regulatory requirement group and claims a pending_regulatory slot; the
        number is ordered + activated once the provider approves (asynchronous).
        A customer may hold several same-country numbers in review at once; a
        double-submit of the SAME attempt is deduped via `submissionId`.

        For an ID-card document requirement, carriers commonly require BOTH sides:
        combine the front and back into a single file before uploading (the
        dashboard does this automatically). A one-sided ID is a common decline
        reason; fix it via POST /v1/whatsapp/phone-numbers/{id}/remediate.

        Before submitting, call GET /v1/whatsapp/phone-numbers/availability to
        check the country has deliverable inventory and, for geographic-match
        countries, which area the address must be in — otherwise the submission
        can pass review yet never be assignable a number.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, country]
              properties:
                profileId: { type: string }
                country: { type: string }
                submissionId: { type: string, description: Idempotency token for this submission attempt. A retry/double-submit with the same token returns the same number; omit and each call creates a new number. }
                quantity: { type: integer, minimum: 1, maximum: 5, default: 1, description: 'Provision several same-country numbers from one submission (1-5). The single verification covers all of them; each number is billed only when it activates. Numbers that fail to order are skipped (best-effort).' }
                reuse: { type: boolean, description: Reuse a prior approved verification for this country (skips document/field collection; places the order immediately). }
                reuseFrom: { type: string, description: 'Which approved verification to reuse when several exist: the phone number it was originally approved for (GET reusable.options[].fromPhoneNumber). Omitted = newest. No match = 409.' }
                endUserFirstName: { type: string, description: End user's legal first name. Required when the country has an action/ID-verification (Onfido) requirement. }
                endUserLastName: { type: string, description: End user's legal last name. Same condition as endUserFirstName. }
                values:
                  type: object
                  additionalProperties: { type: string }
                  description: requirementId → textual value
                documents:
                  type: array
                  description: One per document requirement. Each is EITHER inline base64 OR a `documentId` returned by POST /v1/whatsapp/phone-numbers/kyc/upload-document (use the upload endpoint for large files to stay under the request-size limit).
                  items:
                    oneOf:
                      - type: object
                        required: [requirementId, filename, base64]
                        properties:
                          requirementId: { type: string }
                          filename: { type: string }
                          base64: { type: string }
                      - type: object
                        required: [requirementId, documentId]
                        properties:
                          requirementId: { type: string }
                          documentId: { type: string, description: Id from POST /v1/whatsapp/phone-numbers/kyc/upload-document. }
                address:
                  type: object
                  properties:
                    requirementId: { type: string }
                    country_code: { type: string }
                    business_name: { type: string }
                    first_name: { type: string }
                    last_name: { type: string }
                    street_address: { type: string }
                    locality: { type: string }
                    administrative_area: { type: string }
                    postal_code: { type: string }
      responses:
        '200':
          description: KYC submitted (or already submitted); number pending review.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [kyc_submitted, kyc_reused, kyc_already_submitted] }
                  phoneNumber:
                    type: object
                    description: The first/primary number, kept at the top level for backward compatibility. See `numbers` for the full set when `quantity` > 1.
                    properties:
                      id: { type: string }
                      status: { type: string }
                      country: { type: string }
                  numbers:
                    type: array
                    description: Every number provisioned from this submission. Length equals the requested `quantity` on full success (fewer if some orders failed; best-effort). The first element mirrors `phoneNumber`.
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        status: { type: string }
                        phoneNumber: { type: string }
                        country: { type: string }
        '400': { description: "Validation error (e.g. address not in-country, file too large)" }
        '409': { description: reuse requested but no prior approved verification exists for this country }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/phone-numbers/kyc/upload-document:
    post:
      operationId: uploadWhatsAppNumberKycDocument
      deprecated: true
      tags: [WhatsApp Phone Numbers]
      summary: Upload a KYC document
      description: |
        Deprecated alias of `/v1/phone-numbers/kyc/upload-document`; same contract. New
        integrations should use that path.

        Upload ONE document and get back its provider document id, to reference
        from POST /v1/whatsapp/phone-numbers/kyc via `documents[].documentId`.
        Send the RAW file bytes as the request body (not base64); put the filename
        in the `X-Filename` header. Uploading documents one-per-request keeps each
        request under the ~4.5MB body limit. The document streams straight to the
        number provider and is not stored by Zernio.
      security:
        - bearerAuth: []
      parameters:
        - { name: X-Filename, in: header, required: true, schema: { type: string }, description: URL-encoded original filename. }
      requestBody:
        required: true
        content:
          application/octet-stream:
            schema: { type: string, format: binary }
      responses:
        '200':
          description: Document uploaded.
          content:
            application/json:
              schema:
                type: object
                properties:
                  documentId: { type: string, description: "Reference this id in the KYC submit's documents[].documentId." }
        '400': { description: "Missing X-Filename, empty body, or file too large (over 20MB)." }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/phone-numbers/kyc/validate-address:
    post:
      operationId: validateWhatsAppNumberKycAddress
      deprecated: true
      tags: [WhatsApp Phone Numbers]
      summary: Pre-validate KYC address
      description: |
        Deprecated alias of `/v1/phone-numbers/kyc/validate-address`; same contract. New
        integrations should use that path.

        Optional early check for the address step of a Tier 4 (end-user identity)
        registration: validates a postal address for deliverability BEFORE the full
        KYC submit, so it can be corrected before any documents are uploaded. The
        full submit (POST /v1/whatsapp/phone-numbers/kyc) re-validates the address,
        so this call is purely a fast feedback path and skipping it is safe. Only
        the postal address is sent (no documents, no gov-ID fields). A region
        (`administrative_area`) is required by the validator; when it is omitted the
        pre-check is skipped and `{ ok: true, skipped: true }` is returned (the
        final submit still validates).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [country, street_address, locality, postal_code]
              properties:
                country: { type: string, minLength: 2, maxLength: 2, description: ISO 3166-1 alpha-2 country code. }
                street_address: { type: string }
                locality: { type: string, description: City / town. }
                administrative_area: { type: string, description: "State / province / region. When omitted, the pre-check is skipped (the final submit still validates)." }
                postal_code: { type: string }
      responses:
        '200':
          description: Address is deliverable, or the pre-check was skipped (no region supplied).
          content:
            application/json:
              schema:
                type: object
                properties:
                  ok: { type: boolean, example: true }
                  skipped: { type: boolean, description: "true when no `administrative_area` was supplied, so no pre-check ran." }
        '400':
          description: |
            The country isn't offered, or the address could not be verified. When the
            provider returned usable corrections, `details.addressSuggestions` carries
            them per field for a one-click "apply suggestion" card. (Flat error
            envelope: `error` is the human message; `code`/`param`/`details` are
            top-level siblings.)
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, description: Human-readable message. }
                  type: { type: string }
                  code: { type: string, example: INVALID_FIELD_VALUE }
                  param: { type: string, example: address }
                  details:
                    type: object
                    properties:
                      addressSuggestions:
                        type: array
                        items:
                          type: object
                          properties:
                            field: { type: string, example: administrative_area }
                            label: { type: string, example: State / Province }
                            value: { type: string, example: Dublin }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/phone-numbers/kyc/share:
    post:
      operationId: createWhatsAppNumberKycLink
      deprecated: true
      tags: [WhatsApp Phone Numbers]
      summary: Create a hosted KYC link
      description: |
        Deprecated alias of `/v1/phone-numbers/kyc/share`; same contract. New
        integrations should use that path.

        Create a single-use, 7-day hosted KYC link that your end customer
        completes WITHOUT a Zernio login — useful when the person who holds the
        ID and address is not your team. They fill the regulated verification on
        a Zernio-hosted page; the number provisions under YOUR account once they
        submit. Only regulated (KYC) countries are valid: a country that does not
        require KYC returns 400.

        White-label the page with `branding` (your company name, logo, brand
        color). Supply `redirect_url` to send the end customer back to your own
        site after a successful submit (completion params are appended — see
        below). Listen for the `whatsapp.number.kyc_submitted` webhook to react
        when the form is completed.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, country]
              properties:
                profileId: { type: string }
                country: { type: string, minLength: 2, maxLength: 2, description: ISO 3166-1 alpha-2 country code (must be a regulated/KYC country). }
                branding:
                  type: object
                  description: Optional white-label of the hosted page the end customer sees.
                  properties:
                    companyName: { type: string, maxLength: 60, description: 'Your company name, shown on the hosted page.' }
                    logoUrl: { type: string, format: uri, description: Logo shown above the form. }
                    brandColor: { type: string, pattern: '^#[0-9a-fA-F]{6}$', description: 'Hex color (e.g. #1a73e8) used as a brand accent on the page.' }
                redirect_url:
                  type: string
                  format: uri
                  description: |
                    Where to send the end customer's browser after a successful
                    submit. On completion Zernio appends `kyc=submitted` and
                    `country=<ISO-2>` as query params. When omitted, the hosted
                    page shows a built-in confirmation screen instead.
      responses:
        '200':
          description: Hosted KYC link created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, description: The hosted link to send your end customer. }
                  token: { type: string }
                  expiresAt: { type: string, format: date-time }
        '400': { description: Country does not require KYC (not a regulated country). }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/phone-numbers/{id}/remediate:
    get:
      operationId: getWhatsAppNumberRemediation
      deprecated: true
      tags: [WhatsApp Phone Numbers]
      summary: Get declined requirements
      description: |
        Deprecated alias of `/v1/phone-numbers/{id}/remediate`; same contract. New
        integrations should use that path.

        For a number in `regulatory_declined`, returns ONLY the requirements the
        reviewer flagged declined, as a form spec (same shape as the KYC form GET).
        The customer fixes just those — Telnyx supports correcting a declined
        requirement group and re-submitting it (no new number/group). Falls back
        to the full spec if the provider exposes no per-requirement flags.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: WhatsAppPhoneNumber id. }
      responses:
        '200':
          description: The declined requirements to fix.
          content:
            application/json:
              schema:
                type: object
                properties:
                  country: { type: string }
                  numberType: { type: string }
                  declineReason: { type: [string, "null"] }
                  fields: { type: array, items: { type: object }, description: Same field shape as GET /v1/whatsapp/phone-numbers/kyc. }
        '400': { description: Number is not awaiting remediation }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Number not found }
    post:
      operationId: remediateWhatsAppNumber
      deprecated: true
      tags: [WhatsApp Phone Numbers]
      summary: Resubmit a declined number
      description: |
        Deprecated alias of `/v1/phone-numbers/{id}/remediate`; same contract. New
        integrations should use that path.

        Submit corrected values/documents for the declined requirement(s). We
        PATCH them onto the SAME requirement group and re-submit it for approval;
        the number goes `regulatory_declined` → `pending_regulatory`. No new
        number and no new billing. Body shape matches the KYC submit (values /
        documents / address) — send only the corrected fields.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                values: { type: object, additionalProperties: { type: string } }
                documents:
                  type: array
                  items:
                    oneOf:
                      - { type: object, required: [requirementId, filename, base64], properties: { requirementId: { type: string }, filename: { type: string }, base64: { type: string } } }
                      - { type: object, required: [requirementId, documentId], properties: { requirementId: { type: string }, documentId: { type: string } } }
                address: { type: object, description: Same shape as the KYC submit address. }
      responses:
        '200':
          description: Re-submitted for approval.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: resubmitted }
                  phoneNumber: { type: object, properties: { id: { type: string }, status: { type: string } } }
        '400': { description: Number is not awaiting remediation / nothing to remediate }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Number not found }

  /v1/phone-numbers/{id}/voice:
    post:
      operationId: enableVoiceOnNumber
      tags: [Voice]
      summary: Enable phone calling on a number
      description: |
        Turns on regular phone (PSTN) calling for one of your numbers and
        configures how inbound calls are handled. Inbound calls route to
        `forwardTo`: your own AI voice agent (Vapi/Retell), a phone, or a SIP
        endpoint. Optional extras: voicemail, business-hours windows, an IVR
        menu, a caller blocklist, recording, and transcription. A number can
        also be voice-enabled with no forward (outbound-only).

        Idempotent, and doubles as the settings update: only fields present in
        the body are written. Omitting `forwardTo` preserves the current
        destination; sending an empty string clears it.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: "Phone number record ID (from GET /v1/phone-numbers)." }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                forwardTo: { type: string, description: "tel:+E164, sip:..., or wss://... destination for inbound calls. Empty string clears the forward (outbound-only); omitted preserves the current one." }
                recordingEnabled: { type: boolean }
                transcriptionEnabled: { type: boolean }
                transcriptionLanguage: { type: string, enum: [auto, en, es] }
                voicemailEnabled: { type: boolean, description: Voicemail is taken when there's no live destination. Default on. }
                voicemailGreeting: { type: string, maxLength: 1000, description: Custom spoken greeting; empty string restores the default. }
                businessHoursEnabled: { type: boolean, description: "Outside the windows, inbound skips the forward and goes to voicemail. Off = 24/7." }
                businessHoursTimezone: { type: string, maxLength: 64, description: IANA timezone the windows are evaluated in. }
                businessHours:
                  type: array
                  maxItems: 21
                  items:
                    type: object
                    required: [day, open, close]
                    properties:
                      day: { type: integer, minimum: 0, maximum: 6, description: 0 = Sunday. }
                      open: { type: string, pattern: '^\d{2}:\d{2}$' }
                      close: { type: string, pattern: '^\d{2}:\d{2}$' }
                blockedCallers:
                  type: array
                  maxItems: 1000
                  items: { type: string }
                  description: E.164 numbers rejected before answer. Replaces the whole list; bare 10-digit values are normalized as US numbers.
                forwardCallerId: { type: string, enum: [business, caller], description: "Caller ID on the forwarded leg: your number (`business`) or the original caller's (`caller`)." }
                ivrEnabled: { type: boolean, description: IVR menu (supersedes the plain forward within business hours). }
                ivrPrompt: { type: string, maxLength: 1000 }
                ivrOptions:
                  type: array
                  maxItems: 12
                  items:
                    type: object
                    required: [digit, forwardTo]
                    properties:
                      digit: { type: string, pattern: '^[0-9*#]$' }
                      forwardTo: { type: string, description: "tel:+E164, sip:..., or wss://... destination for this digit." }
                      label: { type: string, maxLength: 80 }
      responses:
        '200':
          description: Voice enabled; the full effective voice config is echoed back.
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean }
                  phoneNumber: { type: string }
                  pstnForwardTo: { type: [string, "null"] }
                  recordingEnabled: { type: boolean }
                  transcriptionEnabled: { type: boolean }
                  transcriptionLanguage: { type: string, enum: [auto, en, es] }
                  voicemailEnabled: { type: boolean }
                  voicemailGreeting: { type: [string, "null"] }
                  businessHoursEnabled: { type: boolean }
                  businessHoursTimezone: { type: [string, "null"] }
                  businessHours: { type: array, items: { type: object, properties: { day: { type: integer }, open: { type: string }, close: { type: string } } } }
                  blockedCallers: { type: array, items: { type: string } }
                  forwardCallerId: { type: string, enum: [business, caller] }
                  ivrEnabled: { type: boolean }
                  ivrPrompt: { type: [string, "null"] }
                  ivrOptions: { type: array, items: { type: object, properties: { digit: { type: string }, forwardTo: { type: string }, label: { type: string } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Number not found }
        '422': { description: "This number is hosted by your own carrier (brought via WhatsApp embedded signup), so calls can't be enabled on it." }
    delete:
      operationId: disableVoiceOnNumber
      tags: [Voice]
      summary: Disable phone calling on a number
      description: |
        Turns off PSTN calling for the number. The stored forward destination
        and settings are preserved, so re-enabling restores the prior config.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Voice disabled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean, description: Always false after a successful disable. }
                  phoneNumber: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Number not found }

  /v1/phone-numbers/{id}/sms:
    post:
      operationId: enableSmsOnNumber
      tags: [SMS]
      summary: Enable SMS on a number
      description: |
        Turns on SMS for one of your numbers. The number's real carrier
        capability is checked first: some number types can't do SMS at all
        (`smsCapable: false`), and a number still provisioning at the carrier
        returns `notReady: true` (try again once provisioning finishes).

        US numbers additionally need a carrier registration before messages
        deliver; the response tells you which path applies:
        - `alreadyRegistered: true`: a prior registration still covers this
          number; SMS was simply reactivated.
        - `reusable` set: you have an approved registration this number can
          join in one click via
          `POST /v1/phone-numbers/{id}/sms/reuse-registration`
          (no new brand/campaign, no extra carrier fee).
        - `needsRegistration: true` and no `reusable`: start one via
          `POST /v1/sms/registrations`.

        Idempotent: re-running re-attempts any carrier-side setup that failed.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: "Phone number record ID (from GET /v1/phone-numbers)." }
      responses:
        '200':
          description: "Result. Check `enabled`: a 200 with `enabled: false` means the number can't do SMS (`smsCapable: false`) or isn't ready yet (`notReady: true`)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean }
                  id: { type: string, description: The SMS social account ID (present when enabled). }
                  phoneNumber: { type: string }
                  isActive: { type: boolean, description: False for US numbers until their registration is approved. }
                  country: { type: string }
                  smsCapable: { type: [boolean, "null"], description: Null when capability can't be read yet (still provisioning). }
                  mmsCapable: { type: boolean }
                  domesticOnly: { type: boolean }
                  notReady: { type: boolean, description: Number is still provisioning at the carrier; retry shortly. }
                  needsRegistration: { type: boolean, description: US only; a carrier registration is required before delivery. }
                  alreadyRegistered: { type: boolean, description: A prior non-rejected registration already covers this number; no re-submit needed. }
                  registrationStatus: { type: [string, "null"], enum: [pending, approved, rejected, null] }
                  reusable:
                    type: [object, "null"]
                    description: Present when an existing approved registration can cover this number via /sms/reuse-registration.
                    properties:
                      registrationId: { type: string }
                      status: { type: string }
                  message: { type: string, description: Human-readable explanation when `enabled` is false. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Number not found }
        '422': { description: "This number is hosted by your own carrier (brought via WhatsApp embedded signup), so SMS can't be enabled on it." }
    delete:
      operationId: disableSmsOnNumber
      tags: [SMS]
      summary: Disable SMS on a number
      description: |
        Turns off SMS for the number (deactivates its SMS account). The carrier
        registration is untouched, so re-enabling later just reactivates it,
        with no re-registration.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: SMS disabled.
          content:
            application/json:
              schema:
                type: object
                properties:
                  enabled: { type: boolean, description: Always false after a successful disable. }
                  phoneNumber: { type: string }
                  disabled: { type: boolean, description: False when SMS was already off. Legacy field; prefer `enabled`. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Number not found }

  /v1/phone-numbers/{id}/sms/reuse-registration:
    post:
      operationId: reuseSmsRegistrationForNumber
      tags: [SMS]
      summary: Add a number to an existing registration
      description: |
        Attaches this number to your existing approved 10DLC campaign instead
        of running a fresh registration: the number inherits the campaign's
        approval (no new brand or campaign, no extra carrier fee). Enable SMS
        on the number first (`POST /v1/phone-numbers/{id}/sms`; its response
        tells you whether a reusable registration exists).
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Number added to the existing registration.
          content:
            application/json:
              schema:
                type: object
                properties:
                  registrationId: { type: string }
                  status: { type: string, enum: [pending, approved, rejected] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Number not found }
        '409': { description: No existing SMS registration to reuse for this number }

  /v1/phone-numbers/{id}/whatsapp/calling:
    get:
      operationId: getWhatsAppCalling
      tags: [WhatsApp Calling]
      summary: Get calling config for a number
      description: |
        The WhatsApp Business Calling configuration of this number, keyed the
        same way as the POST/PATCH/DELETE below (full read-write on one
        sub-resource). Encrypted secrets are never returned; only a boolean
        saying whether a SIP password is stored. The account-scoped read
        (`GET /v1/whatsapp/calling?accountId=`) remains for callers that only
        know the social account id, and additionally carries account-level
        extras (billing eligibility, current-period spend).
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: "Phone number record ID (from GET /v1/phone-numbers)." }
      responses:
        '200':
          description: Calling config
          content:
            application/json:
              schema:
                type: object
                properties:
                  phoneNumber: { type: string }
                  callingEnabled: { type: boolean }
                  callDeepLink: { type: [string, "null"], description: "Public calling deep link (https://wa.me/call/<number>). Null while calling is disabled." }
                  forwardTo: { type: [string, "null"], description: "tel:+E164 / sip:... / wss://... destination" }
                  recordingEnabled: { type: boolean }
                  sipAuthUsername: { type: [string, "null"] }
                  sipAuthPasswordConfigured: { type: boolean, description: True when a SIP digest password is stored. The plaintext is never returned. }
                  callIconCountries:
                    type: [array, "null"]
                    items: { type: string, minLength: 2, maxLength: 2 }
                  outboundDisabled: { type: boolean, description: "True when the number's country blocks business-initiated (outbound) WhatsApp calling; inbound still works." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Number not found }
    post:
      operationId: enableWhatsAppCalling
      tags: [WhatsApp Calling]
      summary: Enable calling on a number
      description: |
        Enable WhatsApp Business Calling on a connected number. Configures
        Meta calling.status=ENABLED with our Telnyx SIP endpoint, fetches and
        stores the Meta-issued SIP password (encrypted), and snapshots the
        customer's forward-to destination.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: "Phone number record ID (from GET /v1/phone-numbers)." }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, forwardTo]
              properties:
                accountId: { type: string }
                forwardTo: { type: string, description: "tel:+E164 / sip:... / wss://... destination" }
                sipAuthUsername: { type: string }
                sipAuthPassword: { type: string, description: "Stored encrypted, never returned by any endpoint." }
                recordingEnabled: { type: boolean, default: false }
                callIconCountries:
                  type: array
                  items: { type: string, minLength: 2, maxLength: 2 }
      responses:
        '200':
          description: Calling enabled
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  callingEnabled: { type: boolean }
                  sipHostname: { type: string }
                  forwardTo: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Phone number not found }
        '422': { description: "Not eligible to enable calling: not on usage-based billing, or the number's messaging limit is below Meta's ~2,000-daily-recipient threshold (TIER_250). Warm the number up to raise the limit." }
    patch:
      operationId: updateWhatsAppCalling
      tags: [WhatsApp Calling]
      summary: Update calling config
      description: |
        Update fields on an already-enabled number. Only fields present in
        the body are written; `undefined` leaves the stored value alone,
        explicit `null` clears a nullable field. No Meta side effect, this
        only changes local routing state consumed by the Telnyx webhook
        handler.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId: { type: string }
                forwardTo: { type: string }
                sipAuthUsername: { type: [string, "null"] }
                sipAuthPassword: { type: [string, "null"] }
                recordingEnabled: { type: boolean }
                callIconCountries:
                  type: [array, "null"]
                  items: { type: string, minLength: 2, maxLength: 2 }
      responses:
        '200': { description: Updated }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Phone number not found }
        '422': { description: Calling must be enabled before settings can be updated }
    delete:
      operationId: disableWhatsAppCalling
      tags: [WhatsApp Calling]
      summary: Disable calling on a number
      description: |
        Disable calling. Sends calling.status=DISABLED to Meta (best-effort)
        and flips the local `callingEnabled` flag off. forwardTo and SIP
        creds are preserved so a re-enable does not lose the destination.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
      responses:
        '200': { description: Disabled }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Phone number not found }



  /v1/whatsapp/phone-numbers/{phoneNumberId}:
    get:
      operationId: getWhatsAppPhoneNumber
      deprecated: true
      tags: [WhatsApp Phone Numbers]
      summary: Get phone number
      description: |
        Deprecated alias of `/v1/phone-numbers/{id}`; same contract. New
        integrations should use that path.

        Retrieve the current status of a purchased phone number. Poll this to
        track Meta pre-verification (US sync path) and, for regulated (Tier 3/4)
        numbers, the async lifecycle: pending_regulatory → active (or
        regulatory_declined). When a regulated number has an Onfido ID step,
        `onfidoVerificationUrl` appears here once the order is placed — forward
        it to the end user. (Or subscribe to the whatsapp.number.* webhooks
        instead of polling.)
      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, pending_regulatory, regulatory_declined, provisioning, verifying, active, suspended, releasing, released] }
                      country: { type: string }
                      metaPreverifiedId: { type: string }
                      metaVerificationStatus: { type: string }
                      onfidoVerificationUrl: { type: [string, "null"], description: For a regulated number with an Onfido ID step — the link to forward to the end user. Appears once the order is placed; null otherwise. }
                      endUserFirstName: { type: [string, "null"] }
                      endUserLastName: { type: [string, "null"] }
                      regulatoryDeclineReason: { type: [string, "null"], description: Reviewer rejection reason when status is regulatory_declined. }
                      provisionedAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: releaseWhatsAppPhoneNumber
      deprecated: true
      tags: [WhatsApp Phone Numbers]
      summary: Release phone number
      description: |
        Deprecated alias of `/v1/phone-numbers/{id}`; same contract. New
        integrations should use that path.

        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 Sandbox (shared test number) ──────────────────────
  #
  # The sandbox is one Zernio-owned WhatsApp number every user can test
  # against without owning their own number. To prevent abuse of the
  # verified WABA, each user must activate the recipient phone first:
  # we send a verified template TO their phone; they reply (any text);
  # the inbound webhook flips the session to `active`. Only then can
  # the user fire the locked sandbox template at that phone via the
  # standard inbox compose endpoints.
  #
  # Limits per user: 50 messages / 24h, 5 distinct recipients / 24h
  # (effectively 1 — one phone per user). Pending sessions expire after
  # 24h; activated sessions after 7 days.

  /v1/whatsapp/sandbox/sessions:
    get:
      operationId: listWhatsAppSandboxSessions
      tags: [WhatsApp Sandbox]
      summary: List your sandbox sessions
      description: |
        Returns all of the authenticated user's non-expired sandbox sessions
        (pending + active) plus the sandbox phone number. In practice there
        is at most one session per user since the sandbox is one-phone-per-user;
        the array shape is preserved for forward compatibility.
      security:
        - bearerAuth: []
      responses:
        '200':
          description: Sessions retrieved successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  sessions:
                    type: array
                    items: { $ref: '#/components/schemas/WhatsAppSandboxSession' }
                  sandboxNumber:
                    type: [string, "null"]
                    description: The shared sandbox phone number in E.164 form.
                    example: "+12029087457"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Inbox addon required }
    post:
      operationId: createWhatsAppSandboxSession
      tags: [WhatsApp Sandbox]
      summary: Start a sandbox activation
      description: |
        Creates (or refreshes) a pending sandbox session for the given phone and
        immediately fires the verified sandbox template from the shared sandbox
        number to that phone. The session activates when the phone owner replies
        to that WhatsApp message — the reply itself is proof of ownership.

        One phone per user: if the caller already has a non-expired session for
        a DIFFERENT phone, the request is rejected with `invalid_field_value`
        (the message names the existing phone so it can be revoked first).
        Re-creating a session for the SAME phone is idempotent and refreshes
        the verification template.

        If Meta rejects the template send (not a WhatsApp number, paused WABA,
        token issue), the pending row is rolled back and the Meta error message
        is returned in `error` so the caller knows why.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [phone]
              properties:
                phone:
                  type: string
                  description: Recipient phone in international format. Digits, spaces, dashes and a leading `+` are all accepted; the server normalizes to E.164 digits-only.
                  example: "+34688246216"
      responses:
        '200':
          description: Session created or refreshed; verification template sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  session: { $ref: '#/components/schemas/WhatsAppSandboxSession' }
                  sandboxNumber: { type: string, example: "+12029087457" }
        '400':
          description: |
            Returned when (a) phone format is invalid, (b) phone equals the sandbox
            number itself, (c) the user already has a session for a different phone,
            or (d) Meta rejected the template send. The `error` field contains the
            specific reason; `param` is set when a field is at fault.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Inbox addon required }

  /v1/whatsapp/sandbox/sessions/{sessionId}:
    delete:
      operationId: deleteWhatsAppSandboxSession
      tags: [WhatsApp Sandbox]
      summary: Revoke a sandbox session
      description: |
        Hard-deletes the session. The user loses the ability to send to that
        phone via the sandbox until they re-activate it. Existing conversations
        and messages already exchanged with that phone are untouched —
        revocation only blocks FUTURE sends.

        Sessions belonging to other users cannot be revoked; the response is
        the same 400 as "session not found" so existence isn't leaked.
      security:
        - bearerAuth: []
      parameters:
        - name: sessionId
          in: path
          required: true
          description: The session id returned by POST /v1/whatsapp/sandbox/sessions.
          schema: { type: string }
      responses:
        '200':
          description: Session revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
        '400': { description: Invalid or unknown session id }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Inbox addon required }

  # ─── 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/connection#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/connection#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/connection#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/connection#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/connection#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/connection#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/connection#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/connection#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/connection#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/connection#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/connection#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 }
                        version:
                          type: integer
                          description: 1-based version within the flow's clone lineage (Zernio-tracked; Meta has no native versioning). Standalone flows are version 1.
                        lineageId:
                          type: string
                          description: Stable group key for the flow's version lineage (the root flow's ID).
        '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 the Flow JSON from" }
                asVersion: { type: boolean, description: "When cloning, true keeps the clone in cloneFlowId's version lineage (auto-numbered next version); false/absent creates an independent flow. Ignored without cloneFlowId." }
            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 }
                      version: { type: integer, description: Version within the clone lineage }
                      lineageId: { type: string, description: Version-lineage group key }
        '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}/preview:
    get:
      operationId: getWhatsAppFlowPreview
      tags: [WhatsApp Flows]
      summary: Get flow preview URL
      description: |
        Get Meta's public web-preview URL for a flow (drafts included), embeddable as an
        interactive iframe. The link is reused across calls (valid ~30 days); pass
        invalidate=true to mint a fresh one (the previous link stops working).
      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: invalidate, in: query, required: false, schema: { type: boolean }, description: Mint a fresh preview link (default false) }
      responses:
        '200':
          description: Preview URL
          content:
            application/json:
              schema:
                type: object
                properties:
                  preview_url: { type: [string, "null"] }
                  expires_at: { type: [string, "null"] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Flow or account not found }

  /v1/whatsapp/flows/{flowId}/versions:
    get:
      operationId: listWhatsAppFlowVersions
      tags: [WhatsApp Flows]
      summary: List flow versions
      description: |
        List the flow's version history (the clone lineage Zernio tracks, since Meta has no
        native versioning), newest version first. Each entry is enriched with the version's
        live name and status from Meta. A flow with no lineage returns just itself as version 1.
      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: Version history
          content:
            application/json:
              schema:
                type: object
                properties:
                  versions:
                    type: array
                    items:
                      type: object
                      properties:
                        flowId: { type: string }
                        version: { type: integer }
                        parentFlowId: { type: [string, "null"] }
                        name: { type: [string, "null"] }
                        status: { type: [string, "null"] }
                        missing: { type: boolean, description: True when Meta no longer has this flow }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Flow or 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. If omitted, auto-generated as '<flowId>:<uuid>' so the response can be attributed to this flow in the Flow Responses view." }
                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 }

  /v1/whatsapp/flow-responses:
    get:
      operationId: listWhatsAppFlowResponses
      tags: [WhatsApp Flows]
      summary: List flow responses
      description: |
        List the responses customers submitted when completing a flow (parsed from the
        nfm_reply messages received via webhook), newest first. Scope to a single flow
        with `flowId` — this matches responses whose flow_token carries the `<flowId>:`
        prefix that Zernio stamps on auto-generated tokens at send time. Responses sent
        with a custom integrator-supplied flow_token are not attributed to a flow.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
        - { name: flowId, in: query, required: false, schema: { type: string }, description: Scope to responses for this flow }
        - { name: limit, in: query, required: false, schema: { type: integer, maximum: 200, default: 50 }, description: Max responses to return }
      responses:
        '200':
          description: Flow responses
          content:
            application/json:
              schema:
                type: object
                properties:
                  responses:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: Message ID }
                        receivedAt: { type: string, format: date-time }
                        from: { type: [string, "null"], description: Sender wa_id / phone }
                        senderName: { type: [string, "null"] }
                        conversationId: { type: [string, "null"] }
                        flowToken: { type: [string, "null"] }
                        data: { type: object, description: Submitted field values (flow_token removed) }
                        raw: { type: [string, "null"], description: Raw response_json string }
        '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 }
                      messagesSentCount: { type: integer, description: 'Messages sent to the contact, derived live from message history across all linked conversations.' }
                      messagesReceivedCount: { type: integer, description: 'Messages received from the contact, derived live from message history across all linked conversations.' }
                      lastMessageSentAt: { type: [string, "null"], format: date-time, description: 'Timestamp of the most recent outgoing message, or null if none.' }
                      lastMessageReceivedAt: { type: [string, "null"], format: date-time, description: 'Timestamp of the most recent incoming message, or null if none.' }
                      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 }
                        lastActiveAt: { type: [string, "null"], format: date-time, description: 'Most recent message (either direction) in this channel''s conversation, or null if none.' }
                        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, items: { type: object, additionalProperties: true } }
                    variableMapping:
                      type: object
                      description: Maps template variable positions ("1", "2") to contact fields or static values. Resolved per recipient at send time.
                      additionalProperties:
                        type: object
                        properties:
                          field: { type: string, enum: [name, phone, email, company, custom] }
                          customValue: { type: string, description: Static value used when field is "custom" }
                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 } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string }
                message:
                  type: object
                  description: Generic message payload (used for non-WhatsApp platforms).
                  properties:
                    text: { type: string }
                template:
                  type: object
                  description: WhatsApp template payload (used when platform is `whatsapp`).
                  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 { field, customValue }.
                      additionalProperties:
                        type: object
                        properties:
                          field: { type: string, enum: [name, phone, email, company, custom] }
                          customValue: { type: string }
                segmentFilters:
                  type: object
                  description: Recipient segment filters (tags, channels, subscription state).
      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 }
                        errorCode: { type: [integer, "null"], description: 'Meta WhatsApp error code (e.g. 131049 for antispam, 131021 for invalid phone, 131026 for re-engagement required). Only populated for status=failed.' }
                        errorExplanation: { type: [string, "null"], description: 'Plain-language translation of errorCode (e.g. for 131026, that the recipient has likely opted out of marketing messages). Null for unmapped codes; fall back to error.' }
                        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/workflows:
    get:
      operationId: listWorkflows
      summary: List workflows
      description: Returns workflows with run stats. Filter by status or profile.
      tags: [Workflows]
      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: Workflows list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  workflows:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        description: { type: string }
                        platform: { type: string }
                        accountId: { type: string }
                        accountName: { type: string }
                        status: { type: string, enum: [draft, active, paused] }
                        nodeCount: { type: integer }
                        totalStarted: { 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: createWorkflow
      summary: Create workflow
      description: >
        Create a branching conversation workflow (draft) from a node/edge graph. Created in `draft`
        status; activate it to start matching inbound messages. The graph is validated structurally;
        completeness (a trigger node + reachable entry) is required at activation.
      tags: [Workflows]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, accountId, name]
              properties:
                profileId: { type: string }
                accountId: { type: string }
                platform: { type: string, enum: [whatsapp, instagram, facebook, telegram, twitter, bluesky, reddit], default: whatsapp }
                name: { type: string }
                description: { type: string }
                nodes: { type: array, items: { $ref: '#/components/schemas/WorkflowNode' } }
                edges: { type: array, items: { $ref: '#/components/schemas/WorkflowEdge' } }
                entryNodeId: { type: string, description: The trigger node id; derived from the single trigger node if omitted }
      responses:
        '200':
          description: Workflow created
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  workflow:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      description: { type: string }
                      platform: { type: string }
                      status: { type: string }
                      nodeCount: { type: integer }
                      entryNodeId: { type: string }
                      createdAt: { type: string, format: date-time }
        '400': { description: "Invalid graph (duplicate node ids, edges referencing missing nodes, or a WhatsApp-only node on another platform)" }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/workflows/{workflowId}:
    get:
      operationId: getWorkflow
      summary: Get workflow with graph
      description: Returns a workflow including its full node/edge graph and run stats.
      tags: [Workflows]
      parameters:
        - { name: workflowId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Workflow details
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  workflow:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      description: { type: string }
                      platform: { type: string }
                      accountId: { type: string }
                      profileId: { type: string }
                      status: { type: string, enum: [draft, active, paused] }
                      entryNodeId: { type: string }
                      nodes: { type: array, items: { $ref: '#/components/schemas/WorkflowNode' } }
                      edges: { type: array, items: { $ref: '#/components/schemas/WorkflowEdge' } }
                      totalStarted: { 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: updateWorkflow
      summary: Update workflow
      description: >
        Update name, description, the graph, or reassign to a different account. The graph can only
        be modified while the workflow is draft or paused. Account swaps re-validate the graph
        against the new platform (so e.g. moving from WhatsApp to Facebook surfaces a `start_call`
        node as an error instead of silently saving an unrunnable graph).
      tags: [Workflows]
      parameters:
        - { name: workflowId, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string }
                nodes: { type: array, items: { $ref: '#/components/schemas/WorkflowNode' } }
                edges: { type: array, items: { $ref: '#/components/schemas/WorkflowEdge' } }
                entryNodeId: { type: [string, "null"] }
                accountId:
                  type: string
                  description: >
                    Reassign the workflow to a different `SocialAccount`. `platform` and `profileId`
                    are derived server-side from the new account (the client never sends them
                    directly). The account must belong to the caller's workspace and be on a
                    workflow-supported platform (whatsapp, instagram, facebook, telegram, twitter,
                    bluesky, reddit). Changing this triggers a graph revalidation against the new
                    platform.
      responses:
        '200':
          description: Workflow updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  workflow:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      description: { type: string }
                      status: { type: string }
                      entryNodeId: { type: string }
                      nodeCount: { type: integer }
                      updatedAt: { type: string, format: date-time }
        '400': { description: "Invalid graph, or a graph edit attempted while the workflow is active" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      operationId: deleteWorkflow
      summary: Delete workflow
      description: Permanently delete a workflow and all of its executions.
      tags: [Workflows]
      parameters:
        - { name: workflowId, in: path, required: true, schema: { type: string } }
      responses:
        '200': { description: Workflow deleted }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/workflows/{workflowId}/activate:
    post:
      operationId: activateWorkflow
      summary: Activate workflow
      description: Validate the graph is runnable and set the workflow live. Once active, matching inbound messages start executions. Idempotent.
      tags: [Workflows]
      parameters:
        - { name: workflowId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Workflow activated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  workflow:
                    type: object
                    properties:
                      id: { type: string }
                      status: { type: string }
                      entryNodeId: { type: string }
        '400': { description: Incomplete or invalid graph }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/workflows/{workflowId}/pause:
    post:
      operationId: pauseWorkflow
      summary: Pause workflow
      description: Stop matching new inbound messages. In-flight executions continue to completion. Idempotent.
      tags: [Workflows]
      parameters:
        - { name: workflowId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Workflow paused
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  workflow:
                    type: object
                    properties:
                      id: { type: string }
                      status: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/workflows/{workflowId}/executions:
    get:
      operationId: listWorkflowExecutions
      summary: List workflow runs
      description: Returns recent executions (runs) with their status, current node, and accumulated variables.
      tags: [Workflows]
      parameters:
        - { name: workflowId, in: path, required: true, schema: { type: string } }
        - { name: status, in: query, schema: { type: string, enum: [running, waiting, completed, exited, failed] } }
        - { name: limit, in: query, schema: { type: integer, default: 25 } }
        - { name: skip, in: query, schema: { type: integer, default: 0 } }
      responses:
        '200':
          description: Executions list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  executions:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        status: { type: string, enum: [running, waiting, completed, exited, failed] }
                        currentNodeId: { type: string }
                        waitingFor:
                          type: [object, "null"]
                          properties:
                            kind: { type: string, enum: [timer, reply] }
                            nodeId: { type: string }
                        variables: { type: object, additionalProperties: true }
                        platformIdentifier: { type: string }
                        conversationId: { type: string }
                        stepCount: { type: integer }
                        lastError: { type: [string, "null"] }
                        resumeAt: { type: [string, "null"], format: date-time }
                        createdAt: { type: string, format: date-time }
                        updatedAt: { type: string, format: date-time }
                        completedAt: { type: [string, "null"], 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: triggerWorkflow
      summary: Manually start a workflow run
      description: >
        Kick off a run without waiting for an inbound message (useful for testing). Target an existing
        conversation by `conversationId`, or — WhatsApp only — a phone number via `to` (a conversation is
        found or created). `text` seeds the run's `lastMessage` variable. The graph must be runnable.
      tags: [Workflows]
      parameters:
        - { name: workflowId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: Provide either `to` (WhatsApp phone) or `conversationId`.
              properties:
                to: { type: string, description: Recipient phone (WhatsApp only) }
                conversationId: { type: string, description: An existing conversation to run in (required for non-WhatsApp workflows) }
                text: { type: string, description: "Simulated inbound text, seeded as the run's lastMessage variable" }
      responses:
        '200':
          description: Run started
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  execution:
                    type: [object, "null"]
                    properties:
                      id: { type: string }
                      status: { type: string }
                      currentNodeId: { type: string }
                      waitingFor: { type: [object, "null"] }
                      variables: { type: object, additionalProperties: true }
                      conversationId: { type: string }
        '400': { description: "Missing target, invalid graph, or `to` used on a non-WhatsApp workflow" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/workflows/{workflowId}/executions/{executionId}/events:
    get:
      operationId: listWorkflowExecutionEvents
      summary: Get an execution's timeline
      description: >
        Returns the per-step run-log for a single workflow execution: trigger fired, each node
        visited, edge handles taken, errors, and durations. Backed by Tinybird (90-day retention).
        Used by the Runs UI drawer to render the timeline.
      tags: [Workflows]
      parameters:
        - { name: workflowId, in: path, required: true, schema: { type: string } }
        - { name: executionId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Timeline events for the execution
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  execution:
                    type: object
                    properties:
                      id: { type: string }
                      status: { type: string, enum: [running, waiting, completed, exited, failed] }
                      startedAt: { type: [string, "null"], format: date-time }
                      completedAt: { type: [string, "null"], format: date-time }
                  events:
                    type: array
                    description: Events in chronological order (oldest first).
                    items: { $ref: '#/components/schemas/WorkflowExecutionEvent' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/workflows/{workflowId}/duplicate:
    post:
      operationId: duplicateWorkflow
      summary: Duplicate a workflow
      description: >
        Create an independent copy of a workflow's graph, name, description, and account binding.
        The copy is created in `draft` status with fresh execution counters and a new id —
        execution history is NOT copied. Useful for branching off a known-good workflow before
        making experimental edits.
      tags: [Workflows]
      parameters:
        - { name: workflowId, in: path, required: true, schema: { type: string } }
      responses:
        '201':
          description: Workflow duplicated
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  workflow:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      description: { type: string }
                      status: { type: string, enum: [draft] }
                      platform: { type: string }
                      accountId: { type: string }
                      profileId: { type: string }
                      entryNodeId: { type: [string, "null"] }
                      nodeCount: { type: integer }
                      createdAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/workflows/{workflowId}/versions:
    get:
      operationId: listWorkflowVersions
      summary: List a workflow's version history
      description: >
        Returns the snapshot history. A new version is recorded automatically before every PATCH
        to `nodes` / `edges` / `entryNodeId`, and explicitly when a previous version is restored.
        Lightweight list — call `getWorkflowVersion` for the full snapshot graph.
      tags: [Workflows]
      parameters:
        - { name: workflowId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Versions list
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  versions:
                    type: array
                    description: Versions in reverse chronological order (newest first).
                    items:
                      type: object
                      properties:
                        version: { type: integer, description: Monotonically increasing version number }
                        name: { type: string }
                        description: { type: [string, "null"] }
                        createdBy: { type: [string, "null"], description: User id that authored this version }
                        createdByEmail: { type: [string, "null"], description: Denormalized email so the history UI can render without a join }
                        restoredFromVersion: { type: [integer, "null"], description: "When non-null, this snapshot was created by restoring that version" }
                        createdAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/workflows/{workflowId}/versions/{version}:
    get:
      operationId: getWorkflowVersion
      summary: Get a specific workflow version
      description: Returns the full snapshot for a single historical version, including the graph.
      tags: [Workflows]
      parameters:
        - { name: workflowId, in: path, required: true, schema: { type: string } }
        - { name: version, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: Version snapshot
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  version:
                    type: object
                    properties:
                      version: { type: integer }
                      name: { type: string }
                      description: { type: [string, "null"] }
                      entryNodeId: { type: [string, "null"] }
                      nodes: { type: array, items: { $ref: '#/components/schemas/WorkflowNode' } }
                      edges: { type: array, items: { $ref: '#/components/schemas/WorkflowEdge' } }
                      platform: { type: string }
                      accountId: { type: string }
                      profileId: { type: string }
                      createdBy: { type: [string, "null"] }
                      createdByEmail: { type: [string, "null"] }
                      restoredFromVersion: { type: [integer, "null"] }
                      createdAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/workflows/{workflowId}/versions/{version}/restore:
    post:
      operationId: restoreWorkflowVersion
      summary: Restore a workflow version
      description: >
        Replace the current graph with the named version's snapshot. Before the swap, the current
        graph is itself snapshotted as a new version, so a restore is reversible. The workflow must
        be in `draft` or `paused` status (same gate as a normal graph edit). The returned workflow
        carries `restoredFromVersion` so the UI can surface which version was rolled back to.
      tags: [Workflows]
      parameters:
        - { name: workflowId, in: path, required: true, schema: { type: string } }
        - { name: version, in: path, required: true, schema: { type: integer } }
      responses:
        '200':
          description: Workflow restored to the named version
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  workflow:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      description: { type: string }
                      status: { type: string }
                      entryNodeId: { type: [string, "null"] }
                      nodeCount: { type: integer }
                      updatedAt: { type: string, format: date-time }
                  restoredFromVersion: { type: integer }
        '400': { description: "Workflow is not draft/paused, or the named version's graph is invalid for the current platform" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /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. Steps can only be modified while the sequence is draft or paused.
      tags: [Sequences]
      parameters:
        - { name: sequenceId, in: path, required: true, schema: { type: string } }
      requestBody:
        content:
          application/json:
            schema:
              type: object
              properties:
                name: { type: string }
                description: { type: string }
                steps:
                  type: array
                  description: Replace the full step list. Only allowed while the sequence is draft or paused.
                  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
                            additionalProperties:
                              type: object
                              properties:
                                field: { type: string, enum: [name, phone, email, company, custom] }
                                customValue: { type: string }
                exitOnReply: { type: boolean }
                exitOnUnsubscribe: { type: boolean }
      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] }
                        trigger: { type: string, enum: [comment, story_reply] }
                        accountId: { type: string }
                        platformPostId: { type: string }
                        postTitle: { type: string }
                        keywords: { type: array, items: { type: string } }
                        matchMode: { type: string, enum: [exact, contains] }
                        dmMessage: { type: string }
                        buttons:
                          type: array
                          items: { $ref: '#/components/schemas/DmButton' }
                          description: Inline DM buttons (up to 3). Omitted when none are set.
                        commentReply: { type: string }
                        linkTracking: { type: boolean, description: "Whether link buttons in the DM are wrapped in a tracked redirect to count clicks." }
                        clickTag: { type: string, description: "Tag applied to a contact when they click a tracked link." }
                        isActive: { type: boolean }
                        stats:
                          type: object
                          properties:
                            triggered: { type: integer }
                            dmsSent: { type: integer }
                            dmsFailed: { type: integer }
                            uniqueContacts: { type: integer }
                            trackedSends: { type: integer, description: "DMs sent with a trackable (wrapped) link. CTR denominator: divide clicks by this, not dmsSent. Lags dmsSent for campaigns that predate click tracking." }
                            linkClicks: { type: integer, description: "Total clicks on tracked links (bots/prefetch excluded)." }
                            uniqueClicks: { type: integer, description: "Distinct people who clicked a tracked link." }
                            delivered: { type: integer, description: "DMs confirmed delivered (Messenger; IG emits no delivery receipt)." }
                            read: { type: integer, description: "DMs confirmed read (IG messaging_seen / Messenger message_reads)." }
                        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 account.
        When someone comments a matching keyword (or, with `trigger: story_reply`, replies
        to your Instagram story with one), they automatically receive a DM.

        Triggers (`trigger`):
          * `comment` (default): fires on keyword comments on a post or reel.
          * `story_reply`: fires when someone replies to your Instagram story with a keyword,
            and answers them with a DM. Set `platformPostId` to a story media id to scope to
            one story, or omit it to match replies to any story.

        Targeting (comment trigger):
          * Per-post: set `platformPostId` to scope to one specific post (only one active
            per-post automation is allowed per post).
          * Account-wide ("any post"): omit `platformPostId` (and `postId`). The automation
            evaluates every comment on every post on the account. You can stack unlimited
            account-wide automations, each with its own keyword set, and they all run
            independently. Per-post automations take priority on their post.

        Links in the DM's buttons can be click-tracked (`linkTracking`, on by default) and
        clickers optionally tagged (`clickTag`) for segmentation. Stats returned include
        delivered, read, and link clicks.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, accountId, name, dmMessage]
              properties:
                profileId: { type: string }
                accountId: { type: string, description: Instagram or Facebook account ID }
                trigger: { type: string, enum: [comment, story_reply], default: comment, description: "What fires the automation. 'comment' (keyword comment on a post) or 'story_reply' (keyword reply to an Instagram story). For 'story_reply', platformPostId is the story media id (omit for any story)." }
                platformPostId: { type: string, description: "Platform media/post ID (or story media id when trigger=story_reply). Omit for an account-wide (any-post / any-story) automation." }
                postId: { type: string, description: "Zernio post ID. Required only when also targeting a specific post via platformPostId." }
                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. Max 640 chars when buttons are set, otherwise ~1000." }
                buttons:
                  type: array
                  maxItems: 3
                  items: { $ref: '#/components/schemas/DmButton' }
                  description: "Optional inline DM buttons (1-3). Phone buttons are Facebook-only. Omit or pass [] for a plain-text DM."
                commentReply: { type: string, description: Optional public reply to the comment }
                linkTracking: { type: boolean, default: true, description: "Wrap link buttons in the DM in a tracked redirect so clicks are counted (Link Clicks / CTR). Pass false to send links exactly as written. Defaults to on." }
                clickTag: { type: string, description: "Optional tag applied to a contact when they click a tracked link (requires linkTracking). Lets you segment clickers for broadcasts/sequences." }
      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 }
                      trigger: { type: string, enum: [comment, story_reply] }
                      platformPostId: { type: string }
                      keywords: { type: array, items: { type: string } }
                      matchMode: { type: string, enum: [exact, contains] }
                      dmMessage: { type: string }
                      buttons:
                        type: array
                        items: { $ref: '#/components/schemas/DmButton' }
                        description: Inline DM buttons (up to 3). Omitted when none are set.
                      commentReply: { type: string }
                      linkTracking: { type: boolean }
                      clickTag: { 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 per-post automation already exists for this platformPostId. Does not apply to account-wide automations." }

  /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 }
                      trigger: { type: string, enum: [comment, story_reply] }
                      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 }
                      buttons:
                        type: array
                        items: { $ref: '#/components/schemas/DmButton' }
                        description: Inline DM buttons (up to 3). Omitted when none are set.
                      commentReply: { type: string }
                      linkTracking: { type: boolean }
                      clickTag: { 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, inline buttons, comment reply, or active status.
        Pass `buttons: []` to clear all buttons. When `buttons` is non-empty, `dmMessage` (the new
        one if you're changing it, otherwise the stored one) must be 640 characters or less.
      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 }
                buttons:
                  type: array
                  maxItems: 3
                  items: { $ref: '#/components/schemas/DmButton' }
                  description: "Inline DM buttons (1-3). Pass [] to clear all buttons."
                commentReply: { type: string }
                linkTracking: { type: boolean, description: "Wrap link buttons in a tracked redirect to count clicks. Pass false to send links untouched." }
                clickTag: { type: string, description: "Tag applied to a contact when they click a tracked link (requires linkTracking). Empty string clears it." }
                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 }
                      buttons:
                        type: array
                        items: { $ref: '#/components/schemas/DmButton' }
                        description: Inline DM buttons (up to 3). Omitted when none are set.
                      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 730 days max.

        To find the Zernio ad behind a comment you see in Meta Business Manager, filter by
        platformAdId (the Meta ad ID), effectiveObjectStoryId (Facebook), or
        effectiveInstagramMediaId (Instagram) — those are the post/media the ad's engagement
        lives on, and are also returned on each ad's `creative` object. Then call
        GET /v1/ads/{adId}/comments with the returned ad id.
      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: platformAdId, in: query, schema: { type: string }, description: "Meta ad ID. Returns the ad with this platform-side ad ID." }
        - { name: effectiveObjectStoryId, in: query, schema: { type: string }, description: "Facebook `{pageId}_{postId}` of the post the ad's engagement lives on (Meta `effective_object_story_id`). Use to map a Business-Manager-visible post back to the Zernio ad." }
        - { name: effectiveInstagramMediaId, in: query, schema: { type: string }, description: "Instagram media ID of the boosted post (Meta `effective_instagram_media_id`). Use to map a Business-Manager-visible IG post back to the Zernio ad." }
        - { 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 730-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 access required. Legacy plans need the Ads add-on; included by default on usage-based plans.

  /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 }
        - { name: fromDate, in: query, schema: { type: string, format: date }, description: "Start of metrics date range (YYYY-MM-DD, inclusive). Defaults to 90 days ago when both date params are omitted." }
        - { name: toDate, in: query, schema: { type: string, format: date }, description: "End of metrics date range (YYYY-MM-DD, inclusive). Defaults to today. Max 730-day range." }
      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 access required. Legacy plans need the Ads add-on; included by default on usage-based plans.

  /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
      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."
                name: { type: string, maxLength: 255, description: "Rename the campaign (Meta only; other platforms return 501). At least one of budget/bidStrategy/name is required." }
      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
      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 }
                name: { type: string, maxLength: 255, description: "Rename the ad set (Meta only; other platforms return 501). At least one of budget/status/bidStrategy/name is required." }
                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, "null"] }
                  roasAverageFloor: { type: [number, "null"] }
        '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 730 days max.

        Pass `timeIncrement=1` to also get a daily breakdown: each node gains a `daily[]` array of
        per-day metrics (same fields as the aggregated `metrics`) in the same call. Use `dailyLevel`
        (`campaign` default, or `adset` / `ad`) to choose which levels carry the series. This replaces
        calling the tree once per day for per-campaign daily trends.
      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: campaignId, in: query, schema: { type: string }, description: "Restrict the tree to a single campaign by its platform campaign id (the id the platform assigns, e.g. Meta's numeric campaign id). Filters the campaign set itself, so it works regardless of account size and pagination — pass this when you already hold a campaign id instead of paging the tree to find it. Mirrors the `campaignId` filter on GET /v1/ads." }
        - { name: fromDate, in: query, schema: { type: string, format: date }, description: "Start of the METRICS date range (YYYY-MM-DD). Affects only the spend/impression numbers overlaid on each node, NOT which campaigns are returned. 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 730-day range." }
        - { name: sort, in: query, schema: { type: string, enum: [newest, oldest, spend_desc, spend_asc], default: newest }, description: "Campaign-level sort order. `newest` (default) / `oldest` order by the campaign's newest-ad createdAt. `spend_desc` / `spend_asc` order by aggregated spend in the requested date range; campaigns with no spend land at the end." }
        - { name: timeIncrement, in: query, schema: { type: integer, enum: [1] }, description: "Set to `1` to also return a daily breakdown. Mirrors Meta Insights' `time_increment=1`: each node gains a `daily[]` array of per-day metrics (same fields as the aggregated `metrics`) alongside the range total, so you get per-entity daily trends in ONE call instead of calling the tree once per day. Only `1` (daily) is supported. The daily series covers the same date range and uses the same source data as `metrics`. See `dailyLevel` to control which levels carry it." }
        - { name: dailyLevel, in: query, schema: { type: string, enum: [campaign, adset, ad], default: campaign }, description: "Which tree levels get the `daily[]` series when `timeIncrement=1`. `campaign` (default) attaches it on campaign nodes only — the common per-campaign-trend case, and the smallest payload. `adset` adds it on ad sets too; `ad` adds it on every ad in `ads[]` as well (heaviest — a long range × up to 100 ads per ad set). Scope with `campaignId` to keep `ad`-level responses small. Ignored when `timeIncrement` is unset." }
      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 access required. Legacy plans need the Ads add-on; included by default on usage-based plans.

  /v1/ads/timeline:
    get:
      operationId: getAdsTimeline
      tags: [Ad Campaigns]
      summary: Get daily account metrics
      description: |
        Returns daily aggregate metrics across all ads in a SocialAccount as a single
        time series — one row per calendar day in the requested range. Use this for
        dashboards that draw a daily-spend or daily-conversions chart, instead of
        calling `/v1/ads/tree` once per day.

        `accountId` is required. The lookup is sibling-expanded so passing the `metaads`
        ID also includes ads under the linked `facebook` / `instagram` posting account
        (and vice-versa) — same convention as `/v1/ads/tree` and `/v1/ads`.

        Date range defaults to the last 90 days. Capped at 730 days. Ranges older
        than the 90-day cache window trigger an on-demand backfill from the platform
        before returning.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: Social account ID. Sibling-expanded to its linked posting↔ads pair. }
        - { name: adAccountId, in: query, schema: { type: string }, description: "Optional platform-native ad account ID (e.g. Meta `act_…`, TikTok advertiser ID). Use when the connection wraps multiple platform ad accounts and the chart should show one only. Note: rows ingested before 2026-05-13 don't carry this column; the recurring 7-day re-sync repopulates them naturally." }
        - { name: fromDate, in: query, schema: { type: string, format: date }, description: "Inclusive start of metrics range (YYYY-MM-DD). Defaults to 90 days ago." }
        - { name: toDate, in: query, schema: { type: string, format: date }, description: "Inclusive end of metrics range (YYYY-MM-DD). Defaults to today. Max 730-day range." }
        - { name: platform, in: query, schema: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter] }, description: Restrict to one platform. }
      responses:
        '200':
          description: Daily time series of aggregate metrics. Empty `rows` means the account has no ad activity in the range.
          content:
            application/json:
              schema:
                type: object
                properties:
                  rows:
                    type: array
                    items:
                      type: object
                      properties:
                        date: { type: string, format: date }
                        spend: { type: number, description: "Native currency units (matches /ads/tree convention)." }
                        impressions: { type: integer }
                        reach: { type: integer }
                        clicks: { type: integer }
                        engagement: { type: integer }
                        ctr: { type: number, description: "Click-through rate as a percentage (0–100)." }
                        cpc: { type: number, description: "Cost per click in native currency." }
                        cpm: { type: number, description: "Cost per 1000 impressions in native currency." }
                        conversions: { type: integer, description: "Sum of conversion events matching the campaign optimization goal. Meta-only at time of writing." }
                        costPerConversion: { type: number }
                        actions:
                          type: object
                          additionalProperties: { type: number }
                          description: "Per-action-type counts merged across all ads on this day. Keys are platform-native action types."
                        actionValues:
                          type: object
                          additionalProperties: { type: number }
                          description: "Monetary mirror of `actions` in native currency."
                        purchaseValue: { type: number, description: "Sum of purchase-type action values on this day, native currency." }
                        roas: { type: number, description: "Derived purchaseValue / spend." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.

  /v1/ads/{adId}:
    get:
      operationId: getAd
      tags: [Ads]
      summary: Get ad details
      description: |
        Returns an ad with its creative, targeting, status, and performance metrics.

        The `{adId}` path segment accepts any identifier dialect Zernio indexes for the ad:
        - the Zernio internal `_id` (24-char hex)
        - Meta's numeric `platformAdId` (the value shipped in `comment.received` webhooks as `comment.ad.id`)
        - the creative's `effective_object_story_id` (`{pageId}_{postId}` shape, Facebook side)
        - the creative's `effective_instagram_media_id` (Instagram side)

        Any of the four resolve to the same ad. Caller doesn't need a translation step.
      security:
        - bearerAuth: []
      parameters:
        - name: adId
          in: path
          required: true
          schema: { type: string }
          description: |
            Zernio `_id` (hex), Meta `platformAdId` (numeric), or one of the creative's effective story/media IDs. See description for details.
      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, maxLength: 255, description: "Rename the ad. Now propagated to Meta (POST /{ad-id}); non-Meta platforms return 501." }
      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/campaigns/{campaignId}/analytics:
    get:
      operationId: getCampaignAnalytics
      tags: [Ads]
      summary: Get campaign analytics
      description: |
        Returns performance analytics for a whole campaign in one call: summary metrics, a daily
        timeline over the requested date range (summed across the campaign's ads), and optional
        demographic breakdowns. Breakdowns are fetched live from Meta at the campaign level (one call
        per dimension, no per-ad fan-out), so an agency dashboard gets campaign-level age/gender/etc.
        without summing thousands of per-ad reads. `campaignId` is the platform campaign id; pass
        `platform` when a campaign id could be ambiguous across platforms. If no date range is provided,
        defaults to the last 90 days. Date range is capped at 730 days max.
      security:
        - bearerAuth: []
      parameters:
        - { name: campaignId, in: path, required: true, schema: { type: string }, description: "Platform campaign id (platformCampaignId)." }
        - { name: platform, in: query, schema: { type: string }, description: "Disambiguate when the campaign id exists across platforms (e.g. facebook, instagram)." }
        - { 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 730-day range." }
        - name: breakdowns
          in: query
          schema: { type: string }
          description: "Comma-separated breakdown dimensions (Meta only): age, gender, country, publisher_platform, device_platform, region, platform_position, impression_device, video_asset, image_asset, body_asset, title_asset."
      responses:
        '200':
          description: Campaign analytics
          content:
            application/json:
              schema:
                type: object
                properties:
                  campaign:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: [string, "null"] }
                      platform: { type: string }
                      status: { type: [string, "null"], description: "Effective campaign status (ACTIVE when any child ad is active)." }
                      currency:
                        type: [string, "null"]
                        description: "ISO 4217 code of the ad account (e.g. USD, THB). All money values in `summary` and `daily` are in this currency."
                  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 access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
        '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 730 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 730-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 }
                      trigger: { type: string, enum: [comment, story_reply] }
                      status: { type: string }
                      currency:
                        type: [string, "null"]
                        description: "ISO 4217 code of the ad account that owns this ad (e.g. USD, THB, INR). All money values in `summary` and `daily` are in this currency. Null only on legacy ads synced before currency was persisted."
                  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 access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/ads/{adId}/tracking-tags:
    get:
      operationId: getAdTrackingTags
      tags: [Ads]
      summary: Get ad tracking tags
      description: |
        Unified read of the platform's native click-URL tracking params.
        - Meta (facebook/instagram): the creative's `url_tags` (and template_url_spec).
        - Google (googleads): the campaign's `trackingUrlTemplate` + `finalUrlSuffix`.
          Subject to the Google Ads API access-tier daily quota; bulk audits need Standard access.
        - LinkedIn (linkedinads): the campaign's Dynamic UTM `dynamicValueParameters` + `customValueParameters`.
        Returns 405 for platforms without a click-URL tracking surface (TikTok, X, Pinterest).
      security:
        - bearerAuth: []
      parameters:
        - { name: adId, in: path, required: true, schema: { type: string }, description: "Ad id (hex _id, platformAdId, or effective story/media id)." }
      responses:
        '200':
          description: "Tracking tags for the ad's platform (shape varies by platform)."
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string }
                  level: { type: string, enum: [creative, campaign] }
                  urlTags: { type: [string, "null"], description: "Meta: &-joined click-URL params." }
                  templateUrlSpec: { type: [object, "null"], description: "Meta: third-party click-tracking template (Dynamic Ads)." }
                  trackingUrlTemplate: { type: [string, "null"], description: "Google." }
                  finalUrlSuffix: { type: [string, "null"], description: "Google." }
                  dynamicValueParameters: { type: [object, "null"], description: "LinkedIn." }
                  customValueParameters: { type: [object, "null"], description: "LinkedIn." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "Ad not found" }
        '405': { description: "Platform has no click-URL tracking surface" }
    patch:
      operationId: updateAdTrackingTags
      tags: [Ads]
      summary: Set ad tracking tags
      description: |
        Unified update. Send only the fields for the ad's platform:
        - Meta: `urlTags` (array of {key,value}). Meta creatives are immutable, so this rebuilds the
          creative and repoints the ad. By DEFAULT we PRESERVE the existing creative verbatim
          (re-post its object_story_spec + the new url_tags, reusing the image), so you send `urlTags`
          ALONE — no need to read back headline/body/CTA. `creative` (headline, body, callToAction,
          linkUrl, imageUrl) is OPTIONAL and only needed to rebuild explicitly, or for SHARE / page-post
          / dark / asset_feed creatives whose object_story_spec Meta strips (those return 422 asking for
          `creative`).
        - Google: `trackingUrlTemplate` and/or `finalUrlSuffix` (full template strings; account quota applies).
        - LinkedIn: `dynamicValueParameters` and/or `customValueParameters` (campaign-level Dynamic UTM).
      security:
        - bearerAuth: []
      parameters:
        - { name: adId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                urlTags:
                  type: array
                  description: "Meta only. Click-URL params appended to a freshly-rebuilt creative."
                  items:
                    type: object
                    required: [key, value]
                    properties:
                      key: { type: string }
                      value: { type: string }
                creative:
                  type: object
                  description: "Meta only. OPTIONAL — omit to preserve the existing creative verbatim (default). Provide it only to rebuild the creative explicitly, or for creatives whose object_story_spec Meta strips."
                  required: [headline, body, callToAction, linkUrl, imageUrl]
                  properties:
                    headline: { type: string }
                    body: { type: string }
                    callToAction: { type: string }
                    linkUrl: { type: string, format: uri }
                    imageUrl: { type: string, format: uri }
                    videoUrl: { type: string, format: uri }
                trackingUrlTemplate: { type: string, description: "Google only. Full tracking template (must contain {lpurl})." }
                finalUrlSuffix: { type: string, description: "Google only. Parse-only key=value params." }
                dynamicValueParameters: { type: object, additionalProperties: { type: string }, description: "LinkedIn only. key -> dynamic value enum (CAMPAIGN_ID, CAMPAIGN_NAME, CREATIVE_ID, ...)." }
                customValueParameters: { type: object, additionalProperties: { type: string }, description: "LinkedIn only. key -> static value." }
      responses:
        '200': { description: "Updated" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "Ad not found" }
        '405': { description: "Platform has no click-URL tracking surface" }
        '422': { description: "Meta creative cannot be rebuilt (e.g. placement-customized/asset-feed/dark creative)" }
  /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.

        An ad that runs on both Facebook feed and Instagram feed has two separate underlying
        posts with separate comment threads (the creative's effective_object_story_id and
        effective_instagram_media_id). Use the `placement` query param to pick one; with no
        param the Instagram side is returned when it exists, otherwise Facebook. The
        identifiers are read from the ad record (persisted during sync) with a Marketing-API
        fallback for ads that predate the field.

        For Instagram-placed comments, the Instagram account that runs the ad must be connected
        to Zernio — those comments are read through that account's token. If no connected
        Instagram account on the profile can read the ad's media, the call returns
        ads_connection_required (the Facebook side, if any, is still readable via ?placement=facebook).

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

        The `{adId}` path segment accepts any identifier dialect Zernio indexes for the ad:
        Zernio internal `_id` (24-char hex), Meta's numeric `platformAdId` (the value shipped in
        `comment.received` webhooks as `comment.ad.id`), or the creative's
        `effective_object_story_id` / `effective_instagram_media_id`. Caller doesn't need a
        translation step.
      security:
        - bearerAuth: []
      parameters:
        - { name: adId, in: path, required: true, schema: { type: string }, description: "Internal Zernio ad ID (ObjectId)." }
        - { name: placement, in: query, schema: { type: string, enum: [facebook, instagram] }, description: "Which side of the ad to return comments for. Omit to default to the Instagram side when present, else Facebook. Returns ad_not_commentable if the ad has no such placement." }
        - { 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, placement, adId, platformAdId, effectiveStoryId, accountId, lastUpdated]
                    properties:
                      platform: { type: string, enum: [facebook, instagram], description: "Which side these comments are on (same as `placement`)." }
                      placement: { type: string, enum: [facebook, instagram], description: "The placement these comments are for — useful when you didn't pass ?placement= and want to know which one you got." }
                      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 the Facebook side, effective_instagram_media_id for the Instagram side."
                      facebookAccountId:
                        type: [string, "null"]
                        description: "Facebook-only. The connected Facebook Page SocialAccount these comments were read through — pass it as `accountId` (with `effectiveStoryId` as the postId) to /v1/inbox/comments to reply/hide/delete. Null when no connected Page was used (then moderation isn't possible)."
                      instagramUserId:
                        type: string
                        description: "Instagram-only. The Instagram-scoped business ID that owns the boosted media (creative.instagram_user_id)."
                      instagramPermalink:
                        type: string
                        description: "Instagram-only. Public permalink of the boosted IG post (creative.instagram_permalink_url)."
                      instagramAccountId:
                        type: string
                        description: "Instagram-only. The connected Instagram SocialAccount these comments were read through — pass it as `accountId` (with `effectiveStoryId` as the postId) to /v1/inbox/comments to reply/hide/delete."
                      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 access required (legacy plans need the Ads add-on; included by default on usage-based plans), or ad platform is not Meta (code feature_not_available).
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: |
            Ads account token unavailable, or (for Instagram-placed ads) no connected
            Instagram account on the profile can read the ad's media (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 }, description: "ISO country codes. Required for TikTok boosts (TikTok's ad group requires location_ids); optional on other platforms." }
                    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 access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
        '422':
          description: |
            Platform ads connection required (TikTok Ads, X Ads), missing linked
            account, or — for TikTok — the connected TikTok user is not authorized
            as an Identity on the target advertiser. Returned with code
            `ads_connection_required`; the message includes the actionable
            "TikTok Ads Manager → Assets → Identity" remediation step.

  /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, X/Twitter, and LinkedIn. 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 are documented on each property
        below. LinkedIn creates a Single Image or Single Video Ad backed by a
        Direct Sponsored Content "dark post" authored by a Company Page (see
        `organizationId`); supported goals are engagement, traffic, awareness,
        and video_views (video ads use the `video` field; video_views requires
        a video), and traffic ads require `linkUrl`.


        **Idempotency:** this endpoint is not idempotent at the platform level (a
        blind retry creates a second campaign/ad set/ad). Send an `Idempotency-Key`
        header to make retries safe: the first request with a given key creates the
        ad and we store the response; a retry with the same key replays that exact
        response (with `Idempotent-Replayed: true`) instead of creating duplicates.
        Reusing a key with a different body returns 422; a key whose first request
        is still in flight returns 409 (retry after a short backoff). Keys are
        scoped to your credential and expire after 24h.
      security:
        - bearerAuth: []
      parameters:
        - name: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 255 }
          description: >-
            Optional client-generated unique key (e.g. a UUID) that makes create
            retries safe. Same key + same body replays the original response;
            same key + different body → 422; key still processing → 409.
      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 }
                campaignName: { type: string, maxLength: 255, description: "Meta only. Exact campaign name. Overrides the default `<name> - Campaign`." }
                adSetName: { type: string, maxLength: 255, description: "Meta only. Exact ad set name. Overrides the default `<name> - Ad Set`. (For per-ad names on the multi-creative shape, set `name` on each `creatives[]` entry.)" }
                adName: { type: string, maxLength: 255, description: "Meta only. Exact ad name (the single-creative ad object's name). Overrides the default, which is `name`. (For per-ad names on the multi-creative shape, set `name` on each `creatives[]` entry instead.)" }
                tracking:
                  type: object
                  description: "Meta only. Attaches pixel measurement to the ad regardless of the optimization goal (the \"Website events\" tracking row in Ads Manager). `pixelId` becomes the ad's `tracking_specs` (offsite_conversion + fb_pixel); `urlTags` becomes the ad's `url_tags` (click-tracking query params). Applied on the legacy single-creative shape, every ad of the multi-creative shape, and the attach shape. NOTE: tracking lives on the AD object and is not inherited from the ad set, so pass it on EVERY attach call that should carry the pixel."
                  properties:
                    pixelId: { type: string, description: "Meta Pixel ID to attach for offsite-conversion measurement." }
                    urlTags:
                      type: array
                      description: "Click-URL params appended to the ad's destination as `url_tags` (e.g. utm_source)."
                      items:
                        type: object
                        required: [key, value]
                        properties:
                          key: { type: string }
                          value: { type: string }
                goal: { type: string, enum: [engagement, traffic, awareness, video_views, lead_generation, lead_conversion, conversions, app_promotion, catalog_sales], description: "Required on legacy + multi-creative shapes. Inherited from the ad set on the attach shape. Available goals vary by platform. Meta-specific: `conversions` (OUTCOME_SALES) requires `promotedObject.pixelId` + `promotedObject.customEventType` (use a commerce event, e.g. PURCHASE, START_TRIAL); `lead_conversion` (OUTCOME_LEADS, website pixel leads) requires the same pixel + event but with a leads-class event (e.g. LEAD, SUBMIT_APPLICATION, SCHEDULE, CONTACT) — these are rejected under `conversions` because Meta gates conversion events by objective; `lead_generation` is OUTCOME_LEADS with instant forms (`leadGenFormId`), distinct from `lead_conversion`'s website pixel optimization; `app_promotion` requires `promotedObject.applicationId` + `promotedObject.objectStoreUrl`; `catalog_sales` (Advantage+ catalog ads, e.g. vehicle inventory) requires `promotedObject.productSetId` + `promotedObject.pixelId` + `promotedObject.customEventType` and builds a catalog TEMPLATE creative from the copy fields (headline/body/description/linkUrl/callToAction, which may carry catalog template tags like {{product.name}} or {{vehicle.make}}) — no imageUrl/video is sent, Meta renders the visuals per catalog item; discover catalogs via GET /v1/ads/catalogs and product sets via GET /v1/ads/catalogs/{catalogId}/product-sets; single shape only (no creatives[]/adSetId/dynamicCreative/placementAssets); `lead_generation` accepts an optional `promotedObject.pageId` (auto-filled from the connected Page when omitted). TikTok-specific: `conversions` (website-conversion ad group) requires `promotedObject.pixelId` (your TikTok Pixel ID) and accepts an optional `promotedObject.customEventType` (a TikTok `optimization_event` code like `ON_WEB_ORDER`, `INITIATE_ORDER`, `ON_WEB_REGISTER`, `FORM`); to inherit a pixel + event from an existing ad group, pass `adSetId` instead. LinkedIn-specific: `engagement`, `traffic`, `awareness`, and `video_views` are supported for standalone ads (creates a Direct Sponsored Content single image or single video ad). `traffic` requires `linkUrl`; `video_views` requires the `video` field. For `lead_generation` / `conversions` on LinkedIn — or to promote an existing post — use `POST /v1/ads/boost`." }
                optimizationGoal: { type: string, description: "Meta only. Explicit ad-set `optimization_goal` (e.g. `LANDING_PAGE_VIEWS`, `LINK_CLICKS`, `REACH`, `IMPRESSIONS`, `OFFSITE_CONVERSIONS`, `THRUPLAY`, `LEAD_GENERATION`). Overrides the default derived from `goal` (e.g. `traffic` defaults to `LINK_CLICKS`). Forwarded verbatim to Meta, which validates compatibility with the campaign objective and rejects incompatible combinations." }
                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." }
                status:
                  type: string
                  enum: [ACTIVE, PAUSED]
                  description: "Meta only. Publish state of the created ad set + ad. Omitted or ACTIVE publishes live (default, back-compat); PAUSED creates them paused and skips activation, so you can review before they spend."
                budgetLevel:
                  type: string
                  enum: [adset, campaign]
                  default: adset
                  description: |
                    Meta only. Where the budget lives, which selects the Meta budget model:
                      - `adset` (default): ABO (Ad-set Budget Optimization). The budget is set on the
                        ad set. This is the back-compatible behaviour — omit this field to keep it.
                      - `campaign`: CBO (Campaign Budget Optimization / Advantage Campaign Budget). The
                        budget AND `bidStrategy` are set on the CAMPAIGN, and Meta distributes spend
                        across ad sets automatically.
                    Meta requires the budget at exactly one level, never both. Non-Meta platforms ignore
                    this field. Ignored on the attach shape (`adSetId`), which inherits the existing budget.
                currency: { type: string }
                headline: { type: string, description: "Required for Meta, Google, Pinterest, and LinkedIn on legacy + attach shapes (skip for multi-creative — use `creatives[].headline`). Ignored for TikTok and X/Twitter. Max: Meta=255, Google=30, Pinterest=100, LinkedIn=400. On LinkedIn this is the ad's headline (the bold text on the creative); for traffic ads it's the link card title." }
                longHeadline: { type: string, maxLength: 90, description: "Google Display only — defaults to `headline` if omitted. On LinkedIn, reused as the optional secondary description text on traffic (link) ads; omitted if not provided." }
                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). On LinkedIn this is the post commentary (the intro text shown above the ad). Max: Google=90, Pinterest=500." }
                description: { type: string, maxLength: 255, description: "Meta only (facebook/instagram). Link description — the secondary text shown below the headline (Meta's link_data.description; on video creatives mapped to video_data.link_description). When omitted, Meta auto-pulls the destination URL's OpenGraph description. Applies on legacy, attach, and placementAssets shapes; for multi-creative use creatives[].description (this field is the shared fallback). For multi-text variations use dynamicCreative.descriptions instead." }
                callToAction: { type: string, enum: [LEARN_MORE, SHOP_NOW, SIGN_UP, BOOK_TRAVEL, CONTACT_US, DOWNLOAD, GET_OFFER, GET_QUOTE, SUBSCRIBE, WATCH_MORE, REGISTER, JOIN, ATTEND, REQUEST_DEMO, VIEW_QUOTE, APPLY, SEE_MORE, BUY_NOW], description: "Required on legacy + attach shapes for Meta. Honoured on TikTok (passes through to the Spark Ad creative's `call_to_action`) and on LinkedIn (the CTA button on the ad; defaults to LEARN_MORE when `linkUrl` is set). LinkedIn accepts: LEARN_MORE, SIGN_UP, DOWNLOAD, SUBSCRIBE, REGISTER, JOIN, ATTEND, REQUEST_DEMO, VIEW_QUOTE, APPLY, SEE_MORE, SHOP_NOW, BUY_NOW. Ignored by Google, Pinterest, and X/Twitter." }
                linkUrl: { type: string, format: uri, description: "Required on legacy + attach shapes (skip for multi-creative). On LinkedIn it's the ad's destination URL; required for `traffic` ads, optional for `engagement` / `awareness`. NOT required when `goal` is `lead_generation` (the ad opens a Lead Gen form instead of a destination)." }
                leadGenFormId: { type: string, description: "Meta Lead Gen forms only (facebook/instagram). The leadgen_forms ID to attach to the ad's creative — create one via POST /v1/ads/lead-forms. REQUIRED when `goal` is `lead_generation`, and on every ATTACH (`adSetId`) call that targets a lead ad set (the form attaches per-ad; Meta rejects a formless ad in a lead ad set). Ignored otherwise. The ad set's promoted_object.page_id + LEAD_GENERATION optimization + destination_type ON_AD are derived automatically from the goal. Both `placementAssets` (per-placement creative) and `dynamicCreative` (multi-text / multi-asset pool, e.g. multiple headlines and primary texts) ARE supported on instant-form lead ads — the form is attached for you, and for `dynamicCreative` the ad set is created as a Dynamic Creative ad set automatically (Meta requires that for any multi-text feed; there is no non-DCO multi-text path). Send a single `imageUrls` entry plus your text variations to get Meta's \"Multiple Text Options\" behavior on a lead ad." }
                imageUrl: { type: string, format: uri, description: "Image creative for Meta/Google/Pinterest/LinkedIn on legacy + attach shapes (mutually exclusive with `video`). Required for LinkedIn ads unless `video` is set. 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. For LinkedIn the image is uploaded to LinkedIn under the authoring Company Page (see `organizationId`); recommended ratio 1.91:1 (e.g. 1200×627)." }
                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 (facebook, instagram) and LinkedIn. When set, creates a VIDEO ad on the legacy (or, for Meta, attach) shape. Mutually exclusive with `imageUrl`. For Meta multi-creative, set `video` per entry inside `creatives[]` instead. For LinkedIn the video is uploaded to LinkedIn under the authoring Company Page (see `organizationId`) and the campaign format is set to SINGLE_VIDEO; LinkedIn ignores `thumbnailUrl` (it auto-generates the poster frame) — supply MP4 H.264/AAC, 3s-30min, 75KB-500MB."
                  required: [url]
                  properties:
                    url: { type: string, format: uri, description: "Public URL of the video. Meta: uploaded via chunked transfer on /act_X/advideos, then the request blocks on Meta's transcoding until status.video_status === 'ready'. LinkedIn: uploaded via the Videos API (multipart), then the request blocks until LinkedIn finishes transcoding (status AVAILABLE) — short clips take ~10-30s." }
                    thumbnailUrl: { type: string, format: uri, description: "Public URL of a still-image thumbnail for the video. OPTIONAL: when omitted on Meta, the poster is auto-generated from Meta's own preferred video thumbnail (the same candidates Ads Manager shows), so video ads publish without supplying one. Provide it to control the poster frame exactly (uploaded as an ad image and referenced in object_story_spec.video_data). Ignored by LinkedIn (auto-generated poster frame)." }
                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:
                      name: { type: string, maxLength: 255, description: "Exact name for this ad. Falls back to `<name> #N` (N = 1-based position)." }
                      headline: { type: string, maxLength: 255 }
                      body: { type: string }
                      description: { type: string, maxLength: 255, description: "Link description for this ad (link_data.description; video creatives: video_data.link_description). Falls back to the top-level `description`; when both are omitted Meta scrapes the destination URL's OG description." }
                      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`. thumbnailUrl is optional — when omitted, the poster is auto-generated from Meta's preferred video thumbnail."
                        required: [url]
                        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[]`.

                    The attached ad takes the full single-creative surface:
                    `headline`/`body`/`description`/`callToAction` plus either
                    `imageUrl`/`video` OR `placementAssets` (its own per-placement
                    Feed/Story assets), and `leadGenFormId` when the target is a
                    lead ad set (the parent must be ON_AD — true for ad sets
                    created via goal `lead_generation`; Meta rejects a formless ad
                    there, so pass the form on EVERY attached ad). This is the way
                    to build N full ads sharing one ad set: create the first ad
                    via the normal shape, then attach the rest one call each.

                    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.
                existingCampaignId:
                  type: string
                  description: |
                    Meta only. Add the new ad set under this EXISTING campaign
                    instead of creating a new one (multi-ad-set audience testing).
                    The new ad set's budget is matched to the campaign's mode
                    automatically: for a CBO campaign (campaign-level budget) omit
                    `budgetAmount`/`budgetType` — the campaign owns the budget; for
                    an ABO campaign pass them (they go on the new ad set). On
                    failure only the new ad set is cleaned up; the existing campaign
                    is left untouched and is never (re)activated. Mutually exclusive
                    with `adSetId` and `creatives[]`.
                existingCreativeId:
                  type: string
                  description: |
                    Meta only. Reuse an EXISTING ad creative by id instead of
                    building a new one from the copy/media fields (which are then
                    ignored). Combine with `existingCampaignId` to build a
                    multi-ad-set campaign that shares one creative. Mutually
                    exclusive with `creatives[]`, `dynamicCreative`, and
                    `placementAssets`. The creative id used is returned as
                    `creativeId` on the create response.
                businessName: { type: string, maxLength: 25, description: "Google Display only" }
                boardId: { type: string, description: "Pinterest only. Board ID (auto-creates if not provided)." }
                organizationId: { type: string, description: "LinkedIn only. The Company Page that authors the Direct Sponsored Content (\"dark\") post backing the ad — accepts a numeric organization ID or a full `urn:li:organization:N` URN. Required unless the resolved `accountId` is a connected LinkedIn Company-Page account (defaults to that page) or the LinkedIn ad account is org-owned (defaults to the account's owning organization). The authenticated member must be an ADMINISTRATOR or DIRECT_SPONSORED_CONTENT_POSTER of this page (and the page must be associated with the ad account), or LinkedIn returns 403. Ignored by every other platform." }
                countries: { type: array, items: { type: string }, description: "ISO 3166-1 alpha-2 country codes (e.g. ['NL']). Defaults to ['US'] when no `cities` or `regions` are provided. (LinkedIn currently honours country-level targeting only.)" }
                cities:
                  type: array
                  description: |
                    Meta-only. City-level geo targeting. Each city is targeted by Meta's opaque `key` (the city ID) which can be looked up via `GET /v1/ads/targeting/search?type=city&q=<name>&country_code=<ISO>`. Optional `radius` + `distance_unit` extend the targeting beyond the city limits (e.g. radius 25 km around the city center). Both must be set together, or both omitted (Meta defaults to ~16 km when omitted).

                    Cannot overlap with the same country in `countries` (Meta returns a "locations overlap" error). Either drop the country or scope it to a different country.
                  items:
                    type: object
                    required: [key]
                    properties:
                      key: { type: string, description: "Meta city ID, from /v1/ads/targeting/search results." }
                      radius: { type: number, description: "Optional radius around the city. Must be set together with distance_unit." }
                      distance_unit: { type: string, enum: [mile, kilometer], description: "Unit for radius. Required if radius is set." }
                regions:
                  type: array
                  description: |
                    Meta-only. Region-level (state/province) geo targeting. Each region is targeted by Meta's opaque `key` (the region ID) which can be looked up via `GET /v1/ads/targeting/search?type=region&q=<name>&country_code=<ISO>`.
                  items:
                    type: object
                    required: [key]
                    properties:
                      key: { type: string, description: "Meta region ID, from /v1/ads/targeting/search results." }
                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 }
                zips:
                  type: array
                  description: "Postal/ZIP geo targeting. `key` is the platform's postal location ID from /v1/ads/targeting/search?dimension=geo&geoType=zip. Supported on Meta, Google, TikTok, Pinterest, X."
                  items:
                    type: object
                    required: [key]
                    properties:
                      key: { type: string }
                      name: { type: string }
                metros:
                  type: array
                  description: "DMA / metro-area geo targeting. `key` is the platform's metro ID from /v1/ads/targeting/search?dimension=geo&geoType=metro."
                  items:
                    type: object
                    required: [key]
                    properties:
                      key: { type: string }
                      name: { type: string }
                customLocations:
                  type: array
                  description: "Point-radius (lat/lng) geo targeting. Meta only (custom_locations). Rejected on platforms without radius support."
                  items:
                    type: object
                    required: [latitude, longitude, radius, distanceUnit]
                    properties:
                      latitude: { type: number, minimum: -90, maximum: 90 }
                      longitude: { type: number, minimum: -180, maximum: 180 }
                      radius: { type: number }
                      distanceUnit: { type: string, enum: [mile, kilometer] }
                      name: { type: string }
                      address: { type: string }
                behaviors:
                  type: array
                  description: "Behaviour entities from /v1/ads/targeting/search?dimension=behavior. Supported on Meta and TikTok. Each must include id."
                  items:
                    type: object
                    required: [id]
                    properties:
                      id: { type: string }
                      name: { type: string }
                incomeTier:
                  type: string
                  enum: [top_5, top_10, top_10_25, top_25_50]
                  description: |
                    Normalized household-income tier. Meta and TikTok express all four; Google maps only
                    `top_10`; rejected on LinkedIn, X, and Pinterest. On Meta, income targeting is incompatible
                    with housing/employment/credit `specialAdCategories`.
                languages: { type: array, items: { type: string }, description: "Language codes (e.g. ['en']). Restricts the audience by language." }
                placements:
                  type: object
                  description: |
                    Meta only. Manual ad placements. Omit for automatic placements (Meta's default,
                    recommended for most cases — Meta optimises delivery across all eligible surfaces).
                    When set, restricts delivery to the chosen surfaces, mapped onto the ad set's
                    `targeting.{publisher_platforms, facebook_positions, instagram_positions,
                    messenger_positions, audience_network_positions, threads_positions,
                    whatsapp_positions, device_platforms}`. Enum membership is validated here; Meta
                    additionally enforces co-selection rules (e.g. some positions require their parent
                    publisher platform) and returns an actionable error which we surface. Non-Meta
                    platforms reject this field.
                  properties:
                    publisherPlatforms:
                      type: array
                      items: { type: string, enum: [facebook, instagram, threads, messenger, audience_network] }
                      description: "Top-level platforms to deliver on. A position field below is only honoured when its parent platform is included here."
                    facebookPositions:
                      type: array
                      items: { type: string, enum: [feed, right_hand_column, marketplace, video_feeds, story, search, instream_video, facebook_reels, facebook_reels_overlay, profile_feed, notification] }
                    instagramPositions:
                      type: array
                      items: { type: string, enum: [stream, story, explore, explore_home, reels, profile_feed, ig_search, profile_reels] }
                    messengerPositions:
                      type: array
                      items: { type: string, enum: [messenger_home, sponsored_messages, story] }
                    audienceNetworkPositions:
                      type: array
                      items: { type: string, enum: [classic, rewarded_video] }
                    threadsPositions:
                      type: array
                      items: { type: string, enum: [threads_stream] }
                    whatsappPositions:
                      type: array
                      items: { type: string, enum: [status] }
                    devicePlatforms:
                      type: array
                      items: { type: string, enum: [mobile, desktop] }
                      description: "Restrict by device. Omit to deliver on both mobile and desktop."
                savedTargetingId:
                  type: string
                  description: |
                    ID of a `saved_targeting` audience (created via POST /v1/ads/audiences). When set, its stored
                    TargetingSpec is expanded as the base targeting; inline fields on this body merge on top. Lets you
                    reuse a named targeting preset without re-sending every field.
                rawTargeting:
                  type: object
                  additionalProperties: true
                  description: |
                    Meta only. A raw Meta-native targeting spec passed to the ad set VERBATIM (snake_case:
                    `geo_locations`, `age_min`, `excluded_custom_audiences`, `flexible_spec`, `targeting_automation`,
                    business places, etc.) — exactly the shape `GET /v1/ads/{adId}` returns for external ads. Use it to
                    clone a campaign's targeting EXACTLY, preserving advanced fields the camelCase targeting fields can't
                    model. Mutually exclusive with the camelCase targeting fields (countries/regions/cities/interests/
                    ageMin/...), `audienceId`, and `savedTargetingId` (sending both → 422). Sent as-is; Meta validates and
                    surfaces any errors. If cloning an EU campaign, also pass `dsaBeneficiary` / `dsaPayor` (those are
                    separate fields, not part of targeting).
                specialAdCategories:
                  type: array
                  description: |
                    Meta only. Declares the ad's special category, required for housing, employment, credit, or
                    political/social-issue ads (Meta enforces restricted targeting for these). Note: setting a special
                    category disables income/zip targeting on Meta.
                  items: { type: string, enum: [HOUSING, EMPLOYMENT, CREDIT, ISSUES_ELECTIONS_POLITICS] }
                endDate: { type: string, format: date-time, description: Required for lifetime budgets }
                startDate:
                  type: string
                  format: date-time
                  description: |
                    Meta only. Ad-set start time (ISO 8601, e.g. "2026-06-10T09:00:00Z"), mapped to the
                    ad set's `start_time`. When omitted the ad starts delivering immediately. For lifetime
                    budgets Meta also requires `endDate`. (Same `schedule.startDate` semantics already
                    available on `POST /v1/ads/boost`.)
                instagramAccountId:
                  type: string
                  description: |
                    Meta only. Override the Instagram account the ad is delivered as — pass an Instagram
                    Business Account ID (e.g. 17841...), mapped to the creative's `instagram_user_id`.
                    When omitted we auto-resolve the IG account linked to the connected Facebook Page
                    (the existing default). Useful when a Page has more than one eligible IG account.
                dynamicCreative:
                  type: object
                  description: |
                    Meta only. Dynamic Creative: supply a POOL of assets and Meta auto-combines and
                    optimises them into the best-performing variations within a single ad (mapped to the
                    creative's `asset_feed_spec`). When set, the top-level single-creative fields
                    (`imageUrl`, `headline`, `body`, `linkUrl`, `callToAction`) are ignored. Mutually
                    exclusive with the `creatives[]` multi-creative shape. Meta limits: ≤10 images,
                    ≤5 bodies / titles / descriptions.
                  required: [imageUrls]
                  properties:
                    imageUrls:
                      type: array
                      minItems: 1
                      maxItems: 10
                      items: { type: string, format: uri }
                      description: "Pool of image URLs (1-10). Uploaded to the ad account and referenced by hash in the asset feed."
                    bodies:
                      type: array
                      maxItems: 5
                      items: { type: string }
                      description: "Primary-text variations (the body copy)."
                    titles:
                      type: array
                      maxItems: 5
                      items: { type: string }
                      description: "Headline variations."
                    descriptions:
                      type: array
                      maxItems: 5
                      items: { type: string }
                      description: "Description (link caption) variations."
                    linkUrls:
                      type: array
                      items: { type: string, format: uri }
                      description: "Destination URL variations. At least one is required unless `goal` is `lead_generation`."
                    callToActionTypes:
                      type: array
                      items: { type: string, enum: [LEARN_MORE, SHOP_NOW, SIGN_UP, BOOK_TRAVEL, CONTACT_US, DOWNLOAD, GET_OFFER, GET_QUOTE, SUBSCRIBE, WATCH_MORE, REGISTER, JOIN, ATTEND, REQUEST_DEMO, VIEW_QUOTE, APPLY, SEE_MORE, BUY_NOW] }
                      description: "CTA-button variations. Required."
                    adFormat:
                      type: string
                      enum: [SINGLE_IMAGE, CAROUSEL_IMAGE]
                      default: SINGLE_IMAGE
                      description: "Asset-feed ad format. Defaults to SINGLE_IMAGE."
                placementAssets:
                  type: object
                  description: |
                    Meta only. Placement asset customization: pin a SPECIFIC asset (image OR video) to
                    each placement group on a SINGLE ad (e.g. a 9:16 on Stories/Reels and a 4:5 on Feed).
                    The same thing Meta Ads Manager produces with "different creative per placement",
                    mapped to the creative's `asset_feed_spec` + `asset_customization_rules`. Deterministic
                    pinning, NOT the auto-optimizing pool of `dynamicCreative` (mutually exclusive). Works
                    on the legacy single shape AND the attach shape (`adSetId` + placementAssets adds one
                    placement-customized ad to an existing ad set — the way to build N per-placement ads
                    sharing one ad set: create the first normally, attach the rest). Cannot be combined
                    with `creatives[]`. Shared copy (headline, body, link,
                    CTA) comes from the top-level single-creative fields since only the asset varies by
                    placement. Each rule's `placements` accepts the same fields as the top-level
                    `placements` object; Meta enforces co-selection rules and returns an actionable error.

                    Note on text rendering: Meta suppresses primary text and headline on fullscreen
                    placements (Stories and Reels) in actual ad delivery; the fields are accepted and
                    the ad publishes, but the copy is not shown to users. For visible copy on those
                    placements, bake the text into the creative image or video itself.

                    A block is all-image OR all-video, never mixed (Meta's asset_feed_spec carries one ad
                    format). Image mode: `defaultImageUrl` + `rules[].imageUrl`. Video mode:
                    `defaultVideoUrl` + `rules[].videoUrl` (optional `thumbnailUrl`/`defaultThumbnailUrl`
                    posters; Meta auto-generates when omitted). Exactly one catch-all default is required.
                  required: [rules]
                  properties:
                    defaultImageUrl:
                      type: string
                      format: uri
                      description: "Image mode. Catch-all image for any placement no rule matches. Required in image mode (Meta mandates a default rule)."
                    defaultVideoUrl:
                      type: string
                      format: uri
                      description: "Video mode. Catch-all video for any placement no rule matches. Required in video mode."
                    defaultThumbnailUrl:
                      type: string
                      format: uri
                      description: "Video mode (optional). Poster image for the default video; Meta auto-generates one when omitted."
                    rules:
                      type: array
                      minItems: 1
                      maxItems: 10
                      description: "One entry per placement group you want to pin a specific asset to."
                      items:
                        type: object
                        required: [placements]
                        properties:
                          imageUrl:
                            type: string
                            format: uri
                            description: "Image mode. The image to deliver for this rule's placements."
                          videoUrl:
                            type: string
                            format: uri
                            description: "Video mode. The video to deliver for this rule's placements."
                          thumbnailUrl:
                            type: string
                            format: uri
                            description: "Video mode (optional). Poster image for this rule's video; auto-generated when omitted."
                          placements:
                            type: object
                            description: "Placements this asset is pinned to. At least one field must be set (an empty rule is invalid — that role is served by the default asset). Same enums as the top-level `placements` object."
                            properties:
                              publisherPlatforms:
                                type: array
                                items: { type: string, enum: [facebook, instagram, threads, messenger, audience_network] }
                              facebookPositions:
                                type: array
                                items: { type: string, enum: [feed, right_hand_column, marketplace, video_feeds, story, search, instream_video, facebook_reels, facebook_reels_overlay, profile_feed, notification] }
                              instagramPositions:
                                type: array
                                items: { type: string, enum: [stream, story, explore, explore_home, reels, profile_feed, ig_search, profile_reels] }
                              messengerPositions:
                                type: array
                                items: { type: string, enum: [messenger_home, sponsored_messages, story] }
                              audienceNetworkPositions:
                                type: array
                                items: { type: string, enum: [classic, rewarded_video] }
                              threadsPositions:
                                type: array
                                items: { type: string, enum: [threads_stream] }
                              whatsappPositions:
                                type: array
                                items: { type: string, enum: [status] }
                              devicePlatforms:
                                type: array
                                items: { type: string, enum: [mobile, desktop] }
                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." }
                attributionSpec:
                  type: array
                  minItems: 1
                  maxItems: 3
                  items:
                    type: object
                    required: [eventType, windowDays]
                    properties:
                      eventType: { type: string, enum: [CLICK_THROUGH, VIEW_THROUGH, ENGAGED_VIDEO_VIEW] }
                      windowDays: { type: integer, enum: [1, 7, 28] }
                  description: |
                    Meta only. Conversion attribution window for the ad set — maps 1:1 to Meta's
                    ad-set `attribution_spec`. Only honored for conversion goals (`conversions`,
                    `lead_generation`, `app_promotion`); ignored for awareness/traffic/engagement.
                    Omit to use Meta's default (`7-day click` + `1-day view`). Meta enforces the
                    valid combinations: `VIEW_THROUGH` only allows `windowDays: 1` (7d/28d view
                    windows were removed Jan 2026); `ENGAGED_VIDEO_VIEW` only `1` and only alongside
                    `VIEW_THROUGH: 1`; `CLICK_THROUGH: 28` only on certain objectives. Invalid combos
                    surface as a Meta 400.
                    Example: `[{ "eventType": "CLICK_THROUGH", "windowDays": 7 }, { "eventType": "VIEW_THROUGH", "windowDays": 1 }]`
                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).
                brandIdentity:
                  type: object
                  description: |
                    TikTok only. Synthetic Brand Identity used when the ad
                    attributes to a CUSTOMIZED_USER (instead of a real TT_USER
                    @username). Required on the FIRST CUSTOMIZED_USER ad on a
                    `tiktokads` SocialAccount with no cached identity; omit on
                    subsequent ads (the identity is cached on the account after
                    first creation). Non-TikTok platforms ignore this field.

                    Alternative: configure once via `PATCH /v1/connect/tiktok-ads`,
                    then create ads without this field.
                  required: [displayName, imageUrl]
                  properties:
                    displayName:
                      type: string
                      minLength: 1
                      maxLength: 40
                      description: Brand name shown above the ad on TikTok.
                    imageUrl:
                      type: string
                      format: uri
                      description: Public URL of a square brand image (≥98×98 px, JPG/PNG). Used as the brand avatar on the ad.
                identityType:
                  type: string
                  enum: [TT_USER, CUSTOMIZED_USER]
                  description: |
                    TikTok only. Forces the identity attribution on the ad:

                      - `TT_USER`: the posting account's open_id (real @username
                        branding). Requires a connected TikTok posting account
                        on the same profile.
                      - `CUSTOMIZED_USER`: synthetic Brand Identity (display
                        name + avatar). Requires a configured Brand Identity
                        (cached on the `tiktokads` SocialAccount via
                        `PATCH /v1/connect/tiktok-ads`) or an inline
                        `brandIdentity` to create one on the fly.

                    When omitted, defaults to `TT_USER` if a posting account is
                    connected on this profile, else `CUSTOMIZED_USER`. Spark
                    Ads (`POST /v1/ads/boost`) always use `TT_USER` regardless
                    of this field — TikTok requires the original organic
                    post's author identity for Spark.
                promotedObject:
                  type: object
                  description: |
                    What the ad optimises against. Behaviour depends on the platform.

                    **Meta**: forwarded to the ad set's `promoted_object` (snake-cased).
                    Required for goals whose ad-set optimization_goal points at a specific
                    event/page/app (without it Meta rejects the ad-set create with
                    `error_subcode: 1815430` "Please select a promoted object for your ad set"):
                      - `goal: conversions` (OFFSITE_CONVERSIONS): requires `pixelId` + `customEventType`
                      - `goal: app_promotion` (APP_INSTALLS): requires `applicationId` + `objectStoreUrl`
                      - `goal: lead_generation` (LEAD_GENERATION): `pageId` is auto-filled from the connected Page when omitted

                    Other Meta goals (engagement, traffic, awareness, video_views) ignore this field.

                    **TikTok**: only `goal: conversions` uses it.
                      - `pixelId` maps to the ad group's `pixel_id`. Required: a TikTok website-conversion
                        ad group without a pixel is rejected with `40002: Please select a pixel`.
                      - `customEventType` maps to the ad group's `optimization_event` (the pixel event to
                        optimise for). Optional: TikTok accepts a pixel-only auto-bid conversion ad group.
                        See the `customEventType` field below for the valid TikTok codes.

                    The remaining `promotedObject.*` fields are Meta-only. Platforms other than
                    Meta and TikTok ignore `promotedObject` entirely.
                  properties:
                    pixelId:
                      type: string
                      description: |
                        Pixel ID. **Meta:** Facebook Pixel ID, required for `goal: conversions`.
                        **TikTok:** TikTok Pixel ID, required for `goal: conversions`.
                    customEventType:
                      type: string
                      description: |
                        The event the campaign/ad group optimises against.

                        **Meta:** standard event like `PURCHASE`, `LEAD`, `COMPLETE_REGISTRATION`,
                        `ADD_TO_CART`. Uppercased internally so callers can pass any case. Required
                        for `goal: conversions`.

                        **TikTok:** an `optimization_event` code (UPPER_SNAKE, not Meta's vocabulary
                        and not PascalCase), e.g. `ON_WEB_ORDER` (Complete Payment), `INITIATE_ORDER`
                        (Place an Order), `ON_WEB_CART` (Add to Cart), `ON_WEB_REGISTER` (Complete
                        Registration), `FORM` (Submit Form), `ON_WEB_DETAIL` (View Content). Must be
                        one of the events your TikTok Pixel is configured to track; passed through
                        verbatim and validated by TikTok. Optional for `goal: conversions`.
                    pageId:
                      type: string
                      description: |
                        Facebook Page ID. Used by `goal: lead_generation`. Auto-filled from the
                        connected Page when omitted.
                    applicationId:
                      type: string
                      description: "App ID. Required for `goal: app_promotion`."
                    objectStoreUrl:
                      type: string
                      format: uri
                      description: "App Store / Play Store listing URL. Required for `goal: app_promotion`."
                    customConversionId:
                      type: string
                      description: Custom Conversion ID, when optimising against one instead of a standard event.
                    productCatalogId:
                      type: string
                      description: Catalog ID for catalog/Advantage+ Shopping campaigns.
                    productSetId:
                      type: string
                      description: Product Set ID inside the catalog.
      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 access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
        '422':
          description: Platform ads connection required (TikTok Ads, X Ads) or missing linked account

  /v1/ads/leads:
    get:
      operationId: listLeads
      tags: [Ads]
      summary: List submitted leads
      description: >
        Returns persisted Meta Lead Gen leads for your team, newest-first, with
        keyset pagination on `cursor`. Leads are ingested in real time from the
        `leadgen` webhook. Requires the Ads add-on.
      parameters:
        - { name: formId, in: query, schema: { type: string }, description: Filter to a single lead form. }
        - { name: accountId, in: query, schema: { type: string }, description: Filter to a single connected account. }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: since, in: query, schema: { type: integer }, description: Unix seconds; only leads created at/after this Meta timestamp. }
        - { name: cursor, in: query, schema: { type: string }, description: Keyset cursor from a previous response's pagination.cursor. }
      responses:
        '200':
          description: Lead list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: success }
                  leads:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: Zernio lead id. }
                        leadgenId: { type: string, description: Meta lead id. }
                        formId: { type: string }
                        formName: { type: [string, "null"] }
                        accountId: { type: string }
                        adId: { type: [string, "null"] }
                        adsetId: { type: [string, "null"] }
                        campaignId: { type: [string, "null"] }
                        isOrganic: { type: boolean }
                        createdTime: { type: [string, "null"], description: ISO 8601. }
                        fields: { type: object, additionalProperties: { type: string }, description: "Question key → answer." }
                        fieldData: { type: array, items: { type: object }, description: "Raw Meta field_data." }
                  pagination:
                    type: object
                    properties:
                      hasMore: { type: boolean }
                      cursor: { type: [string, "null"] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Ads add-on required. }

  /v1/ads/lead-forms:
    get:
      operationId: listLeadForms
      tags: [Ads]
      summary: List lead forms
      description: Lists the Lead Gen forms owned by the connected Facebook Page. Requires the Ads add-on.
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: Connected facebook account id. }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: cursor, in: query, schema: { type: string } }
      responses:
        '200':
          description: Forms list.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: success }
                  forms: { type: array, items: { type: object } }
                  pagination: { type: object, properties: { hasMore: { type: boolean }, cursor: { type: [string, "null"] } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Ads add-on required. }
    post:
      operationId: createLeadForm
      tags: [Ads]
      summary: Create a lead form
      description: >
        Creates a Lead Gen form on the connected Facebook Page (POST
        /{page-id}/leadgen_forms). NOT idempotent — a retry creates a second
        form. Prefilled question types (EMAIL, PHONE, FULL_NAME, …) must omit
        label/key; CUSTOM questions require both. Requires the Ads add-on.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, name, questions, privacyPolicyUrl]
              properties:
                accountId: { type: string }
                name: { type: string, maxLength: 200 }
                questions:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [type]
                    properties:
                      type: { type: string, description: "EMAIL, PHONE, FULL_NAME, FIRST_NAME, LAST_NAME, CUSTOM, …" }
                      key: { type: string, description: "CUSTOM questions only." }
                      label: { type: string, description: "CUSTOM questions only." }
                      options: { type: array, items: { type: object, properties: { key: { type: string }, value: { type: string } } } }
                      inline_context: { type: string }
                privacyPolicyUrl: { type: string, format: uri }
                privacyPolicyLinkText: { type: string, maxLength: 70 }
                followUpActionUrl: { type: string, format: uri }
                locale: { type: string, example: EN_US }
                thankYouTitle: { type: string }
                thankYouBody: { type: string }
                thankYouButtonText: { type: string }
                thankYouButtonType: { type: string, example: VIEW_WEBSITE }
                thankYouWebsiteUrl: { type: string, format: uri }
                isOptimizedForQuality: { type: boolean }
      responses:
        '200':
          description: Created form.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: success }
                  form: { type: object, properties: { id: { type: string }, name: { type: string } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Ads add-on required. }

  /v1/ads/lead-forms/{formId}:
    get:
      operationId: getLeadForm
      tags: [Ads]
      summary: Get a lead form
      parameters:
        - { name: formId, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Form metadata.
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string }, form: { type: object } } }
        '401': { $ref: '#/components/responses/Unauthorized' }
    delete:
      operationId: archiveLeadForm
      tags: [Ads]
      summary: Archive a lead form
      description: Meta has no hard delete for forms; this archives the form (status=ARCHIVED).
      parameters:
        - { name: formId, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Archived.
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string }, formId: { type: string }, archived: { type: boolean } } }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/ads/lead-forms/{formId}/leads:
    get:
      operationId: listFormLeads
      tags: [Ads]
      summary: List leads for a single form
      description: >
        Returns leads for one form. Serves persisted leads (ingested via the
        leadgen webhook) when available, falling back to a live Graph read.
      parameters:
        - { name: formId, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 } }
        - { name: cursor, in: query, schema: { type: string } }
        - { name: since, in: query, schema: { type: integer }, description: Unix seconds. }
      responses:
        '200':
          description: Leads for the form.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: success }
                  leads:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        createdTime: { type: [string, "null"] }
                        adId: { type: [string, "null"] }
                        formId: { type: string }
                        fields: { type: object, additionalProperties: { type: string } }
                        fieldData: { type: array, items: { type: object } }
                  pagination: { type: object, properties: { hasMore: { type: boolean }, cursor: { type: [string, "null"] } } }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/ads/lead-forms/{formId}/test-leads:
    post:
      operationId: createTestLead
      tags: [Ads]
      summary: Create a test lead
      description: >
        Submits a test lead against the form (POST /{form-id}/test_leads) to
        exercise retrieval without waiting for real ad impressions. Meta allows
        one test lead per form at a time.
      parameters:
        - { name: formId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, fieldData]
              properties:
                accountId: { type: string }
                fieldData:
                  type: array
                  minItems: 1
                  items:
                    type: object
                    required: [name, values]
                    properties:
                      name: { type: string }
                      values: { type: array, items: { type: string }, minItems: 1 }
      responses:
        '200':
          description: Test lead created.
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string }, testLead: { type: object, properties: { id: { type: string } } } } }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/ads/interests:
    get:
      operationId: searchAdInterests
      deprecated: true
      tags: [Ads]
      summary: Search targeting interests
      description: |
        Deprecated alias for `GET /v1/ads/targeting/search?dimension=interest`. Kept for
        backward compatibility, it returns the legacy `{ interests: [...] }` shape rather
        than the normalized `{ results: [...] }`. New integrations should use
        `GET /v1/ads/targeting/search` with `dimension=interest`.
      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 access required. Legacy plans need the Ads add-on; included by default on usage-based plans.

  /v1/ads/targeting/search:
    get:
      operationId: searchAdTargeting
      tags: [Ads]
      summary: Search targeting options
      description: |
        Resolve a human-readable query into the platform's opaque targeting ids used in
        the `TargetingSpec` (`countries`/`regions`/`cities`/`zips`/`metros` geo keys, and
        `interests`/`behaviors` entity ids) on `POST /v1/ads/create`,
        `POST /v1/ads/targeting/reach-estimate`, and `saved_targeting` audiences.

        The `dimension` param selects what is searched, `geo` (locations, further scoped
        by `geoType`), `interest`, `behavior`, or `income`. Availability of each dimension
        varies by platform (e.g. behaviours are Meta/TikTok only). Results are normalized
        across platforms into a single shape, so the same client code consumes Meta,
        TikTok, LinkedIn, X, Pinterest, and Google results.

        For geo queries, `q` should contain only the locality name (e.g. `"Amsterdam"`,
        not `"Amsterdam, NL"`). Use `countryCode` to disambiguate.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Social account ID (a connected account on the target ad platform)." }
        - { name: q, in: query, required: true, schema: { type: string }, description: "Search query. For geo, the locality name only (no region/country suffix)." }
        - { name: dimension, in: query, required: false, schema: { type: string, enum: [geo, interest, behavior, income], default: interest }, description: "What to search. `geo` resolves locations (scope further with `geoType`), `interest`/`behavior` resolve audience entities, `income` resolves income-tier options. Defaults to `interest` for backward compatibility with the deprecated /v1/ads/interests alias." }
        - { name: geoType, in: query, required: false, schema: { type: string, enum: [country, region, city, zip, metro], default: city }, description: "Only used when `dimension=geo`. The kind of location to resolve. Defaults to `city`." }
        - { name: countryCode, in: query, required: false, schema: { type: string, minLength: 2, maxLength: 2 }, description: "ISO 3166-1 alpha-2 country code (e.g. NL) to scope a geo search." }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: "Maximum results to return." }
      responses:
        '200':
          description: Matching targeting options (normalized)
          content:
            application/json:
              schema:
                type: object
                properties:
                  results:
                    type: array
                    items:
                      type: object
                      required: [id, name, type]
                      properties:
                        id: { type: string, description: "The platform's opaque id. Use as a geo `key` (regions/cities/zips/metros) or an entity `id` (interests/behaviors) in TargetingSpec." }
                        name: { type: string, description: "Human-readable label." }
                        type: { type: string, description: "What the result is (e.g. city, region, country, zip, metro, interest, behavior, income)." }
                        path: { type: array, items: { type: string }, description: "Optional breadcrumb of parent labels (e.g. ['United States', 'California', 'Los Angeles']). Disambiguates same-named results." }
                        audienceSize: { type: [integer, "null"], description: "Optional estimated reachable users for this option, when the platform returns it." }
        '400':
          description: Missing or invalid query parameters
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
        '404':
          description: Account not found, or the platform does not support the requested dimension

  /v1/ads/targeting/reach-estimate:
    post:
      operationId: estimateAdReach
      tags: [Ads]
      summary: Estimate audience reach
      description: |
        Returns a normalized pre-flight audience-size estimate for a targeting spec,
        before any campaign is created. Backed by each platform's native reach API
        (Meta `delivery_estimate`, LinkedIn `audienceCounts`, X `audience_summary`,
        Pinterest `audience_sizing`).

        Platforms without a usable pre-flight reach API (Google Search/Display, TikTok)
        return `available: false` with no bounds, so clients can hide or grey out the
        estimate rather than treat the absence as an error.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, adAccountId, spec]
              properties:
                accountId: { type: string, description: "Zernio social account ID on the target ad platform (the estimate runs against its platform)." }
                adAccountId: { type: string, description: "Required. The platform ad-account ID the reach call runs against (Meta act_..., LinkedIn numeric sponsoredAccount ID, Pinterest ad-account ID, X account ID) - every backing reach API is scoped to one ad account. Get it from GET /v1/ads/accounts." }
                spec:
                  allOf: [{ $ref: '#/components/schemas/TargetingSpec' }]
                  description: "The targeting spec to estimate. Same shape used by POST /v1/ads/create."
                optimizationGoal:
                  type: string
                  description: |
                    Optional. The optimization goal the estimate should assume (platform's
                    own vocabulary, e.g. Meta `REACH`, `LINK_CLICKS`, `OFFSITE_CONVERSIONS`).
                    Some platforms vary the estimate by goal; omit to use the platform default.
      responses:
        '200':
          description: Normalized reach estimate
          content:
            application/json:
              schema:
                type: object
                required: [available]
                properties:
                  available: { type: boolean, description: "Whether a pre-flight estimate is available on this platform. False for Google and TikTok." }
                  lower: { type: [integer, "null"], description: "Lower bound of the estimated reachable audience. Present only when available." }
                  upper: { type: [integer, "null"], description: "Upper bound of the estimated reachable audience. Present only when available." }
                  daily: { type: [integer, "null"], description: "Optional estimated daily reach/results at the given budget, when the platform returns it." }
                  currency: { type: [string, "null"], description: "Currency of any monetary fields in the estimate, when applicable." }
                  estimateReady: { type: [boolean, "null"], description: "Meta only. False when Meta is still computing the estimate (the audience is too new); retry shortly." }
        '400':
          description: Missing required fields or a targeting field the platform cannot honour
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/ads/catalogs:
    get:
      operationId: listAdCatalogs
      tags: [Ads]
      summary: List Meta product catalogs
      description: "Lists the Meta product catalogs reachable from an ad account (owned + agency-shared catalogs of the ad account's business), for Advantage+ catalog ads (`goal: catalog_sales` on POST /v1/ads/create — e.g. vehicle inventory catalogs). Read-only; uses scopes customers already granted (no reconnect needed). Catalog contents (items, feeds) are managed in Meta Commerce Manager, not through this API."
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "A facebook, instagram, or metaads social account ID" }
        - { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account ID (act_...)" }
      responses:
        '200':
          description: Catalogs
          content:
            application/json:
              schema:
                type: object
                properties:
                  catalogs:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        vertical: { type: [string, "null"], description: "Catalog vertical (e.g. commerce, vehicles, hotels)" }
                        productCount: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
  /v1/ads/catalogs/{catalogId}/product-sets:
    get:
      operationId: listAdCatalogProductSets
      tags: [Ads]
      summary: List a catalog's product sets
      description: "Lists a Meta product catalog's product sets — the unit a catalog ad promotes. Pass the chosen set as `promotedObject.productSetId` on POST /v1/ads/create with `goal: catalog_sales`."
      security:
        - bearerAuth: []
      parameters:
        - { name: catalogId, in: path, required: true, schema: { type: string }, description: "Meta product catalog ID (from GET /v1/ads/catalogs)" }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "A facebook, instagram, or metaads social account ID" }
      responses:
        '200':
          description: Product sets
          content:
            application/json:
              schema:
                type: object
                properties:
                  productSets:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        name: { type: string }
                        productCount: { type: integer }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
  /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, Pinterest, LinkedIn, and X (Twitter).
      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, tiktokads, pinterest, linkedin, linkedinads, twitter, xads] } }
        - { name: type, in: query, required: false, schema: { type: string, enum: [customer_list, website, lookalike, saved_targeting] }, description: "Filter to one audience type. `saved_targeting` returns stored TargetingSpec audiences (each item carries a `spec`); the other types return uploaded/derived audiences." }
      responses:
        '200':
          description: Audiences
          content:
            application/json:
              schema:
                type: object
                properties:
                  audiences:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: [string, "null"] }
                        platformAudienceId: { type: string }
                        name: { type: string }
                        description: { type: string }
                        type: { type: string, enum: [customer_list, website, lookalike, saved_targeting] }
                        spec:
                          anyOf:
                            - $ref: '#/components/schemas/TargetingSpec'
                            - type: "null"
                          description: "Present (and the only meaningful payload) when `type` is `saved_targeting`. Null for uploaded/derived audience types."
                        platform: { type: string }
                        size: { type: integer }
                        status: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
    post:
      operationId: createAdAudience
      tags: [Ad Audiences]
      summary: Create custom audience
      description: |
        Create a custom audience. `customer_list` is supported on Meta, Google, X, LinkedIn, TikTok, and Pinterest;
        `website` and `lookalike` are Meta-only. `saved_targeting` stores a reusable TargetingSpec (no member upload,
        no adAccountId) that you reference later via `savedTargetingId` on `POST /v1/ads/create`. Upload-backed audiences
        are created empty, add members via `POST /v1/ads/audiences/{audienceId}/users`. On TikTok and Pinterest the
        audience is provisioned lazily on the first member upload (until then its status is `pending`). Create is not
        idempotent, never auto-retry.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              oneOf:
                - type: object
                  title: UploadedOrDerivedAudience
                  description: "customer_list, website, or lookalike audience (uploaded or derived from a source)."
                  required: [accountId, adAccountId, name, type]
                  properties:
                    accountId: { type: string }
                    adAccountId: { type: string, description: "Platform ad account ID. Must start with act_ for Meta; bare platform id for others (Google customer id, X/TikTok/LinkedIn/Pinterest account id)." }
                    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)" }
                - type: object
                  title: SavedTargetingAudience
                  description: "A reusable, stored TargetingSpec. No member upload step, no adAccountId, the spec is the audience. Reference it later via `savedTargetingId` on POST /v1/ads/create."
                  required: [type, accountId, name, spec]
                  properties:
                    type: { type: string, enum: [saved_targeting] }
                    accountId: { type: string, description: "Social account ID on the target ad platform." }
                    name: { type: string, maxLength: 255 }
                    description: { type: string }
                    spec:
                      allOf: [{ $ref: '#/components/schemas/TargetingSpec' }]
                      description: "The targeting spec to store."
      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 access required. Legacy plans need the Ads add-on; included by default on usage-based plans.

  /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, "null"], description: Fresh data from Meta API }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
        '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 access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
        '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 to a customer_list audience. Data is SHA256-hashed server-side before sending to the platform.
        Email is used on every platform; phone is used on Meta only (other platforms ignore it). On TikTok and Pinterest,
        the first upload also provisions the audience (deferred create). LinkedIn uploads are full-replace. 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 access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
        '404': { $ref: '#/components/responses/NotFound' }
        '422':
          description: Audience is not a customer_list type or has no platform ID yet

  /v1/ads/conversions/quality:
    get:
      operationId: getConversionsQuality
      tags: [Ads]
      summary: Get Event Match Quality
      description: |
        Reads Meta Event Match Quality (EMQ) and pixel↔CAPI event coverage for a
        pixel/dataset, live from Meta's Dataset Quality API. Web events only (a
        Meta limitation). Meta-only; other platforms return 405. Requires the Ads add-on.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "SocialAccount _id (must be a metaads account)." }
        - { name: destinationId, in: query, required: true, schema: { type: string }, description: "Meta pixel/dataset ID." }
      responses:
        '200':
          description: Match-quality rows, one per event name.
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, example: metaads }
                  rows:
                    type: array
                    items:
                      type: object
                      properties:
                        eventName: { type: string }
                        compositeScore: { type: number, description: "Composite EMQ score, 0-10." }
                        matchKeys:
                          type: array
                          items:
                            type: object
                            properties:
                              identifier: { type: string }
                              coveragePercentage: { type: number }
                        eventCoveragePercentage: { type: number, description: "Pixel↔CAPI coverage rate for this event." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '405': { description: "Platform does not expose Event Match Quality (non-Meta)." }
  /v1/ads/conversions:
    post:
      operationId: sendConversions
      tags: [Ads]
      summary: Send conversion events
      description: |
        Relay one or more conversion events to the target ad platform's native Conversions API.
        Platform is inferred from the provided `accountId`. Requires the Ads add-on.

        Supported platforms:

        - Meta (`metaads`) via Graph API
        - Google Ads (`googleads`) via Data Manager API `ingestEvents`
        - LinkedIn (`linkedinads`) via `/rest/conversionEvents`
        - TikTok (`tiktokads`) via the Offline Events API `/offline/batch/` — OFFLINE conversions only

        `destinationId` semantics differ per platform:

        - Meta: pixel (dataset) ID, e.g. `123456789012345`
        - Google: conversion action resource name, e.g. `customers/1234567890/conversionActions/987654321`
        - LinkedIn: conversion rule ID or URN, e.g. `104012` or `urn:lla:llaPartnerConversion:104012`
        - TikTok: Offline Event Set ID, e.g. `7057103914977558530`

        TikTok notes: this path sends OFFLINE conversions (in-store / CRM / call-center), not web-pixel
        events. Each event must carry an email or phone (TikTok requires at least one). The connected
        TikTok ads account must have granted the Offline Events permission; older grants must reconnect.

        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. LinkedIn `externalIds` are passed through as plaintext per LinkedIn's spec;
        only emails and phones are hashed.

        For LinkedIn, the connected account must have been authorized after the Conversions API
        rollout (i.e. the OAuth grant must include `rw_conversions`). Older accounts must reconnect.

        Batching is handled automatically. Meta caps at 1000 events per request and rejects the
        entire batch if any event is malformed. Google caps at 2000. LinkedIn caps at 5000 and is
        also all-or-nothing per chunk.

        Dedup: pass a stable `eventId` on every event. Meta and LinkedIn use it to dedupe against
        browser-side pixel/Insight Tag events; Google maps it to `transactionId`.

        Per-platform `eventName` semantics:

        - Meta: free-form. Standard names (Purchase, Lead, ...) match Meta's built-in events; custom strings are accepted.
        - Google: ignored. The conversion action's category determines the event type. Send the standard name closest to your action for documentation, but the platform will not branch on it.
        - LinkedIn: ignored. The conversion rule's `type` (LEAD, PURCHASE, etc.) is locked to the destination at rule-creation time. Send the standard name for documentation; LinkedIn does not branch on it.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, destinationId, events]
              properties:
                accountId:
                  type: string
                  description: SocialAccount ID (metaads, googleads, linkedinads, or tiktokads).
                destinationId:
                  type: string
                  description: |
                    Platform destination identifier. For Meta, the pixel/dataset
                    ID. For Google, the conversion action resource name. For
                    LinkedIn, the conversion rule ID or full
                    `urn:lla:llaPartnerConversion:{id}` URN.
                events:
                  type: array
                  minItems: 1
                  items: { $ref: '#/components/schemas/ConversionEvent' }
                testCode:
                  type: string
                  description: Meta `test_event_code` passthrough. Ignored by Google and LinkedIn.
                consent:
                  type: object
                  description: |
                    Batch-level user consent. Required by Google for EEA/UK
                    events under the Feb 2026 restrictions. Ignored by Meta
                    and LinkedIn.
                  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, linkedinads, tiktokads] }
                  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 for debugging. fbtrace_id for Meta,
                      requestId for Google. Absent for LinkedIn (LinkedIn's
                      conversionEvents endpoint does not surface a trace ID).
        '400':
          description: Invalid body (missing accountId/destinationId/events, malformed event shape).
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: |
            Ads access required (Ads add-on on legacy plans, included on usage-based plans),
            OR (for LinkedIn) the connected account lacks the `rw_conversions` scope and must be reconnected.
        '404':
          description: Account not found or not accessible.
        '429':
          description: |
            LinkedIn token-level rate limit hit (600 requests/min, 300k/day
            per token). Retry with backoff. Meta and Google have their own
            rate-limit semantics surfaced via platform-specific 4xx responses.

  /v1/ads/conversions/adjustments:
    post:
      operationId: adjustConversions
      tags: [Ads]
      summary: Adjust uploaded conversions
      description: |
        Adjust conversions that were previously uploaded via `POST /v1/ads/conversions` —
        retract them, restate their value, or enhance them with first-party data. Requires
        the Ads add-on.

        **Google Ads only.** Google handles adjustments through the classic Google Ads API
        (`ConversionAdjustmentUploadService`); the Data Manager `ingestEvents` path used for
        sending conversions is ingest-only. Meta and LinkedIn have no equivalent, so this
        endpoint returns `405` for those platforms.

        Adjustment types:

        - `RETRACTION` — remove the conversion entirely (refund, chargeback, cancelled order, churn).
        - `RESTATEMENT` — change the conversion's value (upgrade / downgrade / partial refund). Send the corrected **total** value in `restatementValue` (not a delta).
        - `ENHANCEMENT` — attach first-party identifiers (hashed email / phone) to an existing conversion (enhanced conversions applied after the fact).

        Identifying the original conversion (per adjustment):

        - `orderId` — the transaction ID you sent as `eventId` on the original conversion. Recommended, and **required** for `ENHANCEMENT`.
        - or `gclid` + `conversionTime` — the click ID and the original conversion's time (unix seconds). Not available for `ENHANCEMENT`.

        `destinationId` is the conversion action resource name, e.g.
        `customers/1234567890/conversionActions/987654321` (same value you send to
        `POST /v1/ads/conversions`). PII in `user` is hashed with SHA-256 server-side
        (Gmail-specific normalization included). Send plaintext.

        Times are unix seconds; we convert to Google's required
        `yyyy-MM-dd HH:mm:ss+00:00` format. Up to 2000 adjustments per request; partial
        failure is supported (inspect `adjustmentsFailed` / `failures[]`).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, destinationId, adjustments]
              properties:
                accountId:
                  type: string
                  description: SocialAccount ID. Must be a `googleads` account.
                destinationId:
                  type: string
                  description: Conversion action resource name, e.g. `customers/1234567890/conversionActions/987654321`.
                adjustments:
                  type: array
                  minItems: 1
                  maxItems: 2000
                  items:
                    type: object
                    required: [adjustmentType, adjustmentTime]
                    properties:
                      adjustmentType:
                        type: string
                        enum: [RETRACTION, RESTATEMENT, ENHANCEMENT]
                      adjustmentTime:
                        type: number
                        description: When the adjustment occurred, unix seconds.
                      orderId:
                        type: string
                        description: Transaction ID of the original conversion (the `eventId` you sent). Recommended; required for ENHANCEMENT.
                      gclid:
                        type: string
                        description: Alternative key — the original click ID. Pair with `conversionTime`. Not valid for ENHANCEMENT.
                      conversionTime:
                        type: number
                        description: The original conversion's time, unix seconds. Required when identifying by `gclid`.
                      restatementValue:
                        type: number
                        description: RESTATEMENT only — the corrected TOTAL conversion value.
                      currency:
                        type: string
                        description: RESTATEMENT only — ISO 4217 currency for `restatementValue`.
                      user:
                        type: object
                        description: ENHANCEMENT only — first-party identifiers (hashed server-side). At least one of email/phone required.
                        properties:
                          email: { type: string }
                          phone: { type: string }
                      userAgent:
                        type: string
                        description: ENHANCEMENT only — the original conversion's user agent (improves match quality).
      responses:
        '200':
          description: |
            Adjustments processed. Inspect `adjustmentsFailed` and `failures[]` for
            partial failure (Google reports per-row errors via partial failure).
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [googleads] }
                  adjustmentsReceived: { type: integer, description: Adjustments accepted by Google. }
                  adjustmentsFailed: { type: integer, description: Adjustments rejected (see failures). }
                  failures:
                    type: array
                    items:
                      type: object
                      properties:
                        adjustmentIndex: { type: integer, description: Index into the submitted adjustments array. }
                        message: { type: string }
                        code: { oneOf: [{ type: string }, { type: integer }] }
                  traceId: { type: string }
        '400':
          description: Invalid body, or a malformed adjustment (missing key, missing restatementValue for RESTATEMENT, missing identifiers for ENHANCEMENT).
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Ads access required (Ads add-on on legacy plans, included on usage-based plans).
        '404':
          description: Account not found or not accessible.
        '405':
          description: Conversion adjustments are only available for Google Ads (the account's platform is not `googleads`).

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

        For Google and LinkedIn, each destination's `type` reflects the
        conversion type (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.

        For LinkedIn, destinations are returned across every sponsored ad
        account the connected token can access; the `adAccountId` field on
        each destination identifies the parent ad account and is required for
        subsequent CRUD calls (update, delete, associations, metrics).
      security:
        - bearerAuth: []
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
          description: SocialAccount ID (metaads, googleads, linkedinads, or tiktokads).
      responses:
        '200':
          description: Destinations listed
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [metaads, googleads, linkedinads, tiktokads] }
                  destinations:
                    type: array
                    items:
                      type: object
                      properties:
                        id:
                          type: string
                          description: |
                            Destination identifier. Meta: pixel ID. Google:
                            conversion action resource name. LinkedIn:
                            numeric conversion rule ID.
                        name: { type: string }
                        type:
                          type: string
                          description: |
                            Present when the platform locks event type to the
                            destination (Google conversion actions, LinkedIn
                            conversion rules).
                        status: { type: string, enum: [active, inactive] }
                        adAccountId:
                          type: string
                          description: |
                            Set by adapters whose destinations are scoped to a
                            specific ad account (LinkedIn). Pass back on
                            subsequent CRUD calls.
        '400':
          description: Account's platform is not supported by the Conversions API.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: |
            Ads access required (Ads add-on on legacy plans, included on usage-based plans),
            OR (for LinkedIn) the connected account lacks the `rw_conversions` scope and must be reconnected.
        '404':
          description: Account not found or not accessible.
        '429':
          description: LinkedIn rate limit hit. Retry with backoff.

    post:
      operationId: createConversionDestination
      tags: [Ads]
      summary: Create a conversion destination
      description: |
        Create a new conversion destination on the platform. Supported for
        LinkedIn (conversion rule) and Google Ads (conversion action). Meta
        manages destinations in its own UI and returns 405.

        **LinkedIn:** creation is NOT idempotent. A retry creates a second
        destination. Deduplicate before retrying.

        **Google Ads:** calling with a name that already exists reuses the
        existing conversion action transparently (the response is identical to
        a fresh create). Calling with the same name but a different category
        returns a typed `IDEMPOTENCY_CONFLICT` (409) rather than silently
        returning the mismatched action.

        **LinkedIn:** the rule is created with `conversionMethod=CONVERSIONS_API`
        and (by default) auto-associated with all of the ad account's campaigns
        via `autoAssociationType=ALL_CAMPAIGNS`. Pass `autoAssociationType: NONE`
        to opt out and manage associations explicitly via the associations
        endpoints below.

        365-day attribution windows are only valid for `SUBMIT_APPLICATION`,
        `PURCHASE`, `ADD_TO_CART`, `QUALIFIED_LEAD`, and `LEAD` rule types;
        the API rejects other combinations locally.

        **Google Ads:** the conversion action is created with
        `type=UPLOAD_CLICKS` (required for API-uploaded offline conversions,
        immutable after creation). The `type` field carries the Google
        `ConversionActionCategory` enum value, e.g. `PURCHASE`,
        `SUBSCRIBE_PAID`, `SIGNUP`, `IMPORTED_LEAD`, `BOOK_APPOINTMENT`.
        Unified standard event names (e.g. `Purchase`, `Subscribe`,
        `CompleteRegistration`, `Lead`, `Schedule`) are resolved to their
        Google category equivalents automatically. The action defaults to
        secondary (non-primary) to avoid immediately steering Smart Bidding;
        pass `primaryForGoal: true` to opt in.
      security:
        - bearerAuth: []
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
          description: SocialAccount ID (linkedinads or googleads).
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [adAccountId, name, type]
              properties:
                adAccountId:
                  type: string
                  description: |
                    Ad account ID. For LinkedIn: numeric (e.g. "5123456") or
                    full `urn:li:sponsoredAccount:{id}` URN. For Google: numeric
                    customer ID (e.g. "1234567890") or `customers/{id}` form.
                name: { type: string, maxLength: 255 }
                type:
                  type: string
                  description: |
                    Conversion type. For LinkedIn: a unified standard event name
                    (e.g. "Purchase", "Lead", "AddToCart") or a LinkedIn rule
                    type enum (e.g. "PURCHASE", "QUALIFIED_LEAD"). For Google:
                    a unified standard event name (Purchase, Subscribe,
                    CompleteRegistration, Lead, Schedule) or a Google
                    ConversionActionCategory enum value directly (e.g.
                    "PURCHASE", "SUBSCRIBE_PAID", "SIGNUP", "IMPORTED_LEAD",
                    "BOOK_APPOINTMENT"). Unknown values pass through to the
                    platform.
                attributionType:
                  type: string
                  enum: [LAST_TOUCH_BY_CAMPAIGN, LAST_TOUCH_BY_CONVERSION]
                  description: LinkedIn only.
                postClickAttributionWindowSize:
                  type: integer
                  enum: [1, 7, 30, 90, 365]
                  description: |
                    LinkedIn only. Default 30. 365 only allowed for LEAD,
                    PURCHASE, ADD_TO_CART, QUALIFIED_LEAD, SUBMIT_APPLICATION
                    rule types; the API rejects other combinations locally.
                viewThroughAttributionWindowSize:
                  type: integer
                  enum: [1, 7, 30, 90, 365]
                  description: |
                    LinkedIn only. Default 7. Same 365-day-window type
                    restriction applies as `postClickAttributionWindowSize`.
                valueType:
                  type: string
                  enum: [DYNAMIC, FIXED, NO_VALUE]
                  description: |
                    LinkedIn only. DYNAMIC (default) uses the per-event `value`
                    from `sendConversions`. FIXED uses the rule's `value` field.
                    NO_VALUE drops monetary value entirely.
                value:
                  type: object
                  required: [currencyCode, amount]
                  description: |
                    LinkedIn only. Static conversion value. Used when
                    `valueType=FIXED`. The currency should match the ad
                    account's currency.
                  properties:
                    currencyCode: { type: string, minLength: 3, maxLength: 3, description: ISO 4217 (e.g. "USD"). }
                    amount: { type: string, description: 'Decimal string (e.g. "49.99").' }
                autoAssociationType:
                  type: string
                  enum: [ALL_CAMPAIGNS, OBJECTIVE_BASED, NONE]
                  default: ALL_CAMPAIGNS
                  description: |
                    LinkedIn only. Controls campaign association at rule-creation
                    time:
                    - ALL_CAMPAIGNS: associate the rule with every active,
                      paused, and draft campaign in the ad account
                    - OBJECTIVE_BASED: associate only campaigns whose
                      objective matches the rule's type
                    - NONE: don't auto-associate. Manage associations via
                      the `/associations` endpoints below.
                    Note: auto-association runs once at create time; new
                    campaigns added after the rule still need explicit
                    association.
                countingType:
                  type: string
                  enum: [MANY_PER_CLICK, ONE_PER_CLICK]
                  description: |
                    Google Ads only. Whether to count multiple conversions from
                    the same click (MANY_PER_CLICK) or at most one
                    (ONE_PER_CLICK). Defaults to MANY_PER_CLICK if omitted.
                primaryForGoal:
                  type: boolean
                  description: |
                    Google Ads only. When true, the conversion action is marked
                    as primary and immediately influences Smart Bidding. Defaults
                    to false (secondary, record-only) to avoid unintentionally
                    steering the customer's campaigns on creation.
      responses:
        '201':
          description: Destination created
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [linkedinads, googleads] }
                  destination: { $ref: '#/components/schemas/ConversionDestination' }
        '400':
          description: Invalid body or platform validation failure.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: |
            Ads access required (Ads add-on on legacy plans, included on usage-based plans),
            or the connected LinkedIn account lacks the `rw_conversions` scope (reconnect required).
        '404':
          description: Account not found or not accessible.
        '405':
          description: Platform does not support destination creation.
        '409':
          description: |
            Google Ads only. A conversion action with the given name already
            exists but has a different category. Use a different name or use
            the existing destination. Error code: `IDEMPOTENCY_CONFLICT`.
        '429':
          description: Rate limit hit. Retry with backoff.

  /v1/accounts/{accountId}/conversion-destinations/{destinationId}:
    get:
      operationId: getConversionDestination
      tags: [Ads]
      summary: Get a conversion destination
      description: |
        LinkedIn-only today. Returns the full destination record for one
        conversion rule. The `adAccountId` query parameter is required because
        LinkedIn rules are scoped to a sponsored ad account.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string } }
        - { name: destinationId, in: path, required: true, schema: { type: string } }
        - name: adAccountId
          in: query
          required: true
          schema: { type: string }
          description: Numeric ID or full `urn:li:sponsoredAccount:{id}` URN.
      responses:
        '200':
          description: Destination fetched
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [linkedinads] }
                  destination: { $ref: '#/components/schemas/ConversionDestination' }
        '400': { description: Validation error. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Ads add-on or LinkedIn reconnect required. }
        '404': { description: Account or destination not found. }
        '405': { description: Platform does not support fetching a single destination. }
        '429': { description: LinkedIn rate limit hit. Retry with backoff. }

    patch:
      operationId: updateConversionDestination
      tags: [Ads]
      summary: Update a conversion destination
      description: |
        Partial-update a conversion rule. LinkedIn-only today. Whitelisted
        fields: `name`, `enabled`, attribution windows, `valueType`, `value`,
        `attributionType`. The rule's `type` and parent ad account are
        intentionally not exposed for update — recreate the rule if those
        need to change.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string } }
        - { name: destinationId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [adAccountId]
              description: |
                At least one mutable field beyond `adAccountId` is required;
                the route returns 400 if no patch fields are provided.
              properties:
                adAccountId: { type: string }
                name: { type: string, maxLength: 255 }
                enabled:
                  type: boolean
                  description: |
                    Setting `false` is equivalent to calling DELETE — the
                    rule will appear as `inactive` afterwards.
                attributionType:
                  type: string
                  enum: [LAST_TOUCH_BY_CAMPAIGN, LAST_TOUCH_BY_CONVERSION]
                postClickAttributionWindowSize:
                  type: integer
                  enum: [1, 7, 30, 90, 365]
                  description: |
                    365 only allowed for LEAD, PURCHASE, ADD_TO_CART,
                    QUALIFIED_LEAD, SUBMIT_APPLICATION rule types.
                viewThroughAttributionWindowSize:
                  type: integer
                  enum: [1, 7, 30, 90, 365]
                  description: |
                    365 only allowed for LEAD, PURCHASE, ADD_TO_CART,
                    QUALIFIED_LEAD, SUBMIT_APPLICATION rule types.
                valueType:
                  type: string
                  enum: [DYNAMIC, FIXED, NO_VALUE]
                value:
                  type: object
                  description: Used when `valueType=FIXED`.
                  properties:
                    currencyCode: { type: string, description: ISO 4217. }
                    amount: { type: string, description: 'Decimal string (e.g. "49.99").' }
      responses:
        '200':
          description: Destination updated (re-fetched canonical state)
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [linkedinads] }
                  destination: { $ref: '#/components/schemas/ConversionDestination' }
        '400': { description: Invalid body or LinkedIn validation failure. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Ads add-on or LinkedIn reconnect required. }
        '404': { description: Account or destination not found. }
        '405': { description: Platform does not support updating destinations. }
        '429': { description: LinkedIn rate limit hit. Retry with backoff. }

    delete:
      operationId: deleteConversionDestination
      tags: [Ads]
      summary: Delete a conversion destination
      description: |
        LinkedIn-only today. LinkedIn does not expose hard-delete on conversion
        rules — what their UI calls "delete" is the same `enabled: false` flip
        we apply here. The rule remains fetchable via GET with
        `status: 'inactive'`; the unified discovery endpoint hides it by
        default.

        `adAccountId` may be passed as a query parameter (recommended) or as
        a JSON body field for clients that can send DELETE bodies.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string } }
        - { name: destinationId, in: path, required: true, schema: { type: string } }
        - name: adAccountId
          in: query
          schema: { type: string }
          description: Required as query OR in JSON body.
      responses:
        '204': { description: Soft-deleted. }
        '400': { description: adAccountId missing. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Ads add-on or LinkedIn reconnect required. }
        '404': { description: Account or destination not found. }
        '405': { description: Platform does not support deleting destinations. }
        '429': { description: LinkedIn rate limit hit. Retry with backoff. }

  /v1/accounts/{accountId}/conversion-destinations/{destinationId}/associations:
    get:
      operationId: listConversionAssociations
      tags: [Ads]
      summary: List associated campaigns
      description: |
        LinkedIn-only today. Returns the campaigns currently associated with
        this conversion rule. Note that auto-association on rule creation
        runs once at create time; campaigns created after the rule still need
        explicit association.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string } }
        - { name: destinationId, in: path, required: true, schema: { type: string } }
        - name: adAccountId
          in: query
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Associations listed
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [linkedinads] }
                  associations:
                    type: array
                    items:
                      type: object
                      properties:
                        campaignId: { type: string }
                        conversionId: { type: string }
                        associatedAt: { type: integer, description: Epoch ms. }
        '400': { description: Validation error. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Ads add-on or LinkedIn reconnect required. }
        '404': { description: Account or destination not found. }
        '405': { description: Platform does not support associations. }
        '429': { description: LinkedIn rate limit hit. Retry with backoff. }

    post:
      operationId: addConversionAssociations
      tags: [Ads]
      summary: Associate campaigns
      description: |
        Associate one or more campaigns with this conversion rule. Returns a
        per-campaign success/failure result so callers can retry only the
        rows that failed (e.g. wrong campaign type for the rule's objective).
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string } }
        - { name: destinationId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [adAccountId, campaignIds]
              properties:
                adAccountId: { type: string }
                campaignIds:
                  type: array
                  minItems: 1
                  maxItems: 100
                  items:
                    type: string
                    description: Numeric campaign ID or full `urn:li:sponsoredCampaign:{id}` URN.
      responses:
        '200':
          description: |
            Per-campaign batch result. Status is 200 even when some rows
            failed — inspect `failed[]` for details. Inputs that fail local
            URN validation are bucketed into `failed` without ever hitting
            LinkedIn.
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [linkedinads] }
                  succeeded:
                    type: array
                    description: Numeric campaign IDs that were successfully associated.
                    items: { type: string }
                  failed:
                    type: array
                    items:
                      type: object
                      properties:
                        campaignId: { type: string }
                        reason: { type: string }
        '400': { description: Invalid body. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Ads add-on or LinkedIn reconnect required. }
        '404': { description: Account or destination not found. }
        '405': { description: Platform does not support associations. }
        '429': { description: LinkedIn rate limit hit. Retry with backoff. }

    delete:
      operationId: removeConversionAssociations
      tags: [Ads]
      summary: Remove associated campaigns
      description: |
        Remove one or more campaign associations from this conversion rule.
        Pass `adAccountId` and `campaignIds` as query parameters
        (`campaignIds` is comma-separated). The route also accepts a JSON
        body with the same fields for clients that prefer DELETE-with-body,
        but the documented surface is query-only because some SDK code
        generators (e.g. Python) collapse query + body parameters with the
        same name into a single kwarg.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string } }
        - { name: destinationId, in: path, required: true, schema: { type: string } }
        - { name: adAccountId, in: query, required: true, schema: { type: string } }
        - { name: campaignIds, in: query, required: true, schema: { type: string }, description: 'Comma-separated list of campaign IDs.' }
      responses:
        '200':
          description: |
            Per-campaign batch result. Status is 200 even when some rows
            failed — inspect `failed[]` for details.
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [linkedinads] }
                  succeeded:
                    type: array
                    description: Numeric campaign IDs that were successfully removed.
                    items: { type: string }
                  failed:
                    type: array
                    items:
                      type: object
                      properties:
                        campaignId: { type: string }
                        reason: { type: string }
        '400':
          description: |
            Validation error: missing `adAccountId` or `campaignIds`, or
            campaignIds exceeds 100 entries per request.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Ads add-on or LinkedIn reconnect required. }
        '404': { description: Account or destination not found. }
        '405': { description: Platform does not support associations. }
        '429': { description: LinkedIn rate limit hit. Retry with backoff. }

  /v1/accounts/{accountId}/conversion-destinations/{destinationId}/metrics:
    get:
      operationId: getConversionMetrics
      tags: [Ads]
      summary: Get attribution metrics
      description: |
        LinkedIn-only today. Returns conversion-attribution metrics
        (`externalWebsiteConversions`, `externalWebsitePostClickConversions`,
        `externalWebsitePostViewConversions`, `conversionValueInLocalCurrency`,
        `qualifiedLeads`, `costInLocalCurrency`) bucketed by date.

        Date-range constraints (passed through from LinkedIn):
        - `granularity=DAILY` is retained for ~6 months only
        - `granularity=ALL` with a range > 6 months auto-rounds to month boundaries
        - `granularity=MONTHLY`/`YEARLY` retains 24 months

        Throttle: LinkedIn caps adAnalytics at 45M metric values per 5-minute
        window across the calling token. Single-rule queries are well within
        that limit; surfaces as 429 if hit.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string } }
        - { name: destinationId, in: path, required: true, schema: { type: string } }
        - { name: adAccountId, in: query, required: true, schema: { type: string } }
        - { name: startDate, in: query, required: true, schema: { type: string, pattern: '^\\d{4}-\\d{2}-\\d{2}$' } }
        - { name: endDate, in: query, schema: { type: string, pattern: '^\\d{4}-\\d{2}-\\d{2}$' } }
        - name: granularity
          in: query
          schema:
            type: string
            enum: [ALL, DAILY, MONTHLY, YEARLY]
            default: DAILY
      responses:
        '200':
          description: Metrics rows
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [linkedinads] }
                  granularity: { type: string, enum: [ALL, DAILY, MONTHLY, YEARLY] }
                  rows:
                    type: array
                    items:
                      type: object
                      properties:
                        start: { type: string, description: 'YYYY-MM-DD' }
                        end: { type: string, description: 'YYYY-MM-DD (inclusive)' }
                        metrics:
                          type: object
                          additionalProperties: { oneOf: [{ type: number }, { type: string }] }
        '400': { description: Validation error or invalid date range. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Ads add-on or LinkedIn reconnect required. }
        '404': { description: Account or destination not found. }
        '405': { description: Platform does not support metrics readback. }
        '429': { description: LinkedIn analytics rate limit hit. }

  /v1/whatsapp/conversions:
    get:
      operationId: listWhatsAppConversions
      tags: [WhatsApp, Ads]
      summary: List conversion events
      description: |
        Returns the most recent conversion events sent through
        `POST /v1/whatsapp/conversions` for the given WhatsApp account.
        Sourced from delivery logs (Axiom `late` dataset), so the visible
        window is bounded by log retention (about 30 days). Useful for
        rendering a "recent activity" panel on the conversions setup tab
        without standing up a parallel persistence layer.

        Per-event payload mirrors the structured log we write on every
        successful send: `eventName`, `conversationId`, `eventsReceived`,
        `eventsFailed`, `traceId`, `durationMs`, and the wall-clock
        `timestamp`.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp social account ID }
        - name: limit
          in: query
          required: false
          description: Max events to return (1-200, default 50).
          schema:
            type: integer
            minimum: 1
            maximum: 200
            default: 50
      responses:
        '200':
          description: Recent conversion events
          content:
            application/json:
              schema:
                type: object
                properties:
                  events:
                    type: array
                    items:
                      type: object
                      properties:
                        timestamp:
                          type: string
                          format: date-time
                          description: When the event was sent to Meta.
                        eventName:
                          type: string
                          description: One of LeadSubmitted, Purchase, AddToCart, InitiateCheckout, ViewContent.
                        conversationId:
                          type: [string, "null"]
                        eventsReceived:
                          type: [integer, "null"]
                          description: Number of events Meta accepted on this send (usually 1).
                        eventsFailed:
                          type: [integer, "null"]
                          description: Number of events Meta rejected (usually 0).
                        traceId:
                          type: [string, "null"]
                          description: Meta fbtrace_id for cross-referencing in Events Manager.
                        durationMs:
                          type: [integer, "null"]
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }
    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 prerequisite on the WhatsApp account metadata:
          - `metaCapiDatasetId`: the Meta dataset ID linked to the WABA.
            Provision one with `POST /v1/whatsapp/dataset`.

        The WABA ID (already set automatically at connect time) is forwarded as
        `user_data.whatsapp_business_account_id`, which is the per-channel
        attribution identifier Meta requires for WhatsApp events. No Facebook
        Page ID is needed (that field is the Messenger-branch identifier).

        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` on the account, set
            it via POST /v1/whatsapp/dataset) 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 one or more Click-to-WhatsApp (CTWA) ads on Meta under a single campaign and ad set. When tapped, each ad opens a WhatsApp conversation with the business attached to the supplied Facebook Page. The full hierarchy (campaign, ad set, creative(s), ad(s)) 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.

        Supports two mutually-exclusive shapes:

        - **Single-creative**: supply top-level `headline`, `body`, and one of `imageUrl` / `video`. Creates 1 campaign + 1 ad set + 1 ad.

        - **Multi-creative**: supply a `creatives[]` array with N entries (each carrying its own headline, body, and image/video). Creates 1 campaign + 1 ad set + N ads sharing budget and targeting so Meta A/Bs the creatives inside a single auction instead of fragmenting budget across N parallel campaigns. Recommended when launching multiple creative variants for the same campaign.

        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, budgetAmount, budgetType]
              description: |
                In addition to the `required` list, the request must use
                EXACTLY ONE of the two shapes:

                - Single-creative: `headline`, `body`, and one of
                  `imageUrl` / `video` (mutually exclusive).
                - Multi-creative: a non-empty `creatives[]` array. Top-level
                  `headline` / `body` / `imageUrl` / `video` must NOT be set
                  on this shape.

                The route enforces this at the Zod boundary; OpenAPI's
                `required` cannot express the OR 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.
                    On the multi-creative shape, each ad's Meta name gets a
                    " #N" suffix (1-indexed) so Ads Manager shows them as a
                    numbered batch.
                headline:
                  type: string
                  minLength: 1
                  maxLength: 255
                  description: |
                    Single-creative shape only. Mutually exclusive with
                    `creatives[]`.
                body:
                  type: string
                  minLength: 1
                  description: |
                    Primary text shown above the image / video. Single-creative
                    shape only. Mutually exclusive with `creatives[]`.
                imageUrl:
                  type: string
                  format: uri
                  description: |
                    Image asset for single-creative shape. Mutually exclusive
                    with `video` and with `creatives[]`. Required on the
                    single-creative shape 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 for single-creative shape. Mutually
                    exclusive with `imageUrl` and with `creatives[]`. Required
                    on the single-creative shape if `imageUrl` is not supplied.
                creatives:
                  type: array
                  minItems: 1
                  description: |
                    Multi-creative shape: N CTWA ads under one campaign + one
                    ad set, sharing budget and targeting. Mutually exclusive
                    with the top-level single-creative fields (`headline` /
                    `body` / `imageUrl` / `video`). Each entry must supply its
                    own headline, body, and exactly one of `imageUrl` /
                    `video`.
                  items:
                    type: object
                    required: [headline, body]
                    description: |
                      Each entry must also include exactly one of `imageUrl`
                      or `video`.
                    properties:
                      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. Mutually exclusive with this entry's
                          `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 this entry's
                          `imageUrl`. Required if `imageUrl` is not supplied.
                budgetAmount:
                  type: number
                  exclusiveMinimum: 0
                  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"]` only
                    when no other geo (`cities`, `regions`, `zips`, `metros`,
                    `customLocations`) is supplied.
                cities:
                  type: array
                  description: |
                    City-level geo targeting for local CTWA campaigns (e.g.
                    25km radius around Milan). Each entry maps to Meta's
                    TargetingGeoLocationCity. `key` is Meta's city ID
                    (lookupable via GET /v1/ads/targeting/search). `radius`
                    and `distance_unit` are coupled: set both or neither.
                  items:
                    type: object
                    required: [key]
                    properties:
                      key: { type: string, minLength: 1 }
                      radius: { type: number, exclusiveMinimum: 0 }
                      distance_unit: { type: string, enum: [mile, kilometer] }
                regions:
                  type: array
                  description: |
                    Region / state-level geo targeting. `key` is Meta's region
                    ID (lookupable via GET /v1/ads/targeting/search?type=region).
                  items:
                    type: object
                    required: [key]
                    properties:
                      key: { type: string, minLength: 1 }
                zips:
                  type: array
                  description: |
                    ZIP / postal-code geo targeting. `key` is the platform's
                    postal id resolved via /v1/ads/targeting/search.
                  items:
                    type: object
                    required: [key]
                    properties:
                      key: { type: string, minLength: 1 }
                      name: { type: string }
                metros:
                  type: array
                  description: |
                    DMA / metro-area geo targeting. `key` is Meta's metro id
                    (e.g. `DMA:807`).
                  items:
                    type: object
                    required: [key]
                    properties:
                      key: { type: string, minLength: 1 }
                      name: { type: string }
                customLocations:
                  type: array
                  description: |
                    Point-radius geo (Meta `geo_locations.custom_locations`).
                    Use for targeting a radius around a specific lat/long when
                    no Meta city/region key fits. `distanceUnit` is required.
                  items:
                    type: object
                    required: [latitude, longitude, radius, distanceUnit]
                    properties:
                      latitude: { type: number, minimum: -90, maximum: 90 }
                      longitude: { type: number, minimum: -180, maximum: 180 }
                      radius: { type: number, exclusiveMinimum: 0 }
                      distanceUnit: { type: string, enum: [mile, kilometer] }
                      name: { type: string }
                      address: { type: string }
                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.
                bidStrategy:
                  type: string
                  enum:
                    - LOWEST_COST_WITHOUT_CAP
                    - LOWEST_COST_WITH_BID_CAP
                    - COST_CAP
                    - LOWEST_COST_WITH_MIN_ROAS
                  description: |
                    Meta bid strategy applied to the shared ad set. Defaults to
                    `LOWEST_COST_WITHOUT_CAP` (auto-bid) when omitted.
                    `LOWEST_COST_WITH_BID_CAP` and `COST_CAP` require
                    `bidAmount`. `LOWEST_COST_WITH_MIN_ROAS` requires
                    `roasAverageFloor`. CTWA's `optimization_goal` is fixed to
                    `CONVERSATIONS`, but the bid strategy is independent.
                bidAmount:
                  type: number
                  exclusiveMinimum: 0
                  description: |
                    Whole currency units (e.g. `5` = $5.00 on a USD account).
                    Required when `bidStrategy` is `LOWEST_COST_WITH_BID_CAP`
                    or `COST_CAP`; rejected otherwise.
                roasAverageFloor:
                  type: number
                  exclusiveMinimum: 0
                  description: |
                    Decimal ROAS multiplier (e.g. `2.0` = 2.0× ROAS floor).
                    Required when `bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`;
                    rejected otherwise. Meta enforces its own upper bound
                    server-side.
                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(s) created and submitted to Meta for review. Response is a
            tagged union discriminated by `adType`:

            - `adType: "single"` → single-creative request: `{ adType, ad,
              message }` where `ad` is the persisted Ad document.
            - `adType: "multi"` → multi-creative request: `{ adType, ads,
              platformCampaignId, platformAdSetId, message }` where `ads` is
              the array of N persisted Ad documents all sharing the returned
              campaign and ad set IDs.

            Generated SDK clients can narrow on `adType` instead of sniffing
            for field presence.
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/CtwaSingleResponse'
                  - $ref: '#/components/schemas/CtwaMultiResponse'
                discriminator:
                  propertyName: adType
                  mapping:
                    single: '#/components/schemas/CtwaSingleResponse'
                    multi: '#/components/schemas/CtwaMultiResponse'
        '400': { description: Invalid body. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: "Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans." }
        '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.

  /v1/accounts/{accountId}/tracking-tags:
    get:
      operationId: listTrackingTags
      tags: [Tracking Tags]
      summary: List tracking tags
      description: |
        Returns the tracking tags (Meta Pixels) the connected ads account can
        see. Pass `?adAccountId=act_...` to scope the list to a single ad
        account; omit it to list every pixel reachable by the token (the name
        is then suffixed with the ad account it was discovered on, for
        disambiguation). The list view omits `code` — call `getTrackingTag` for
        the install snippet and full detail.

        Meta only today (platform `metaads`); other platforms return 405. The
        `accountId` must be the Meta *ads* SocialAccount created by the Ads
        add-on connect flow, not a Facebook/Instagram posting account. Get your
        `act_...` ids from `GET /v1/ads/accounts`.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string }, description: 'Meta ads SocialAccount id (platform `metaads`).' }
        - { name: adAccountId, in: query, required: false, schema: { type: string }, description: 'Optional. Scope to one ad account, e.g. `act_123456789`.' }
      responses:
        '200':
          description: Tracking tags listed
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [metaads] }
                  tags:
                    type: array
                    items: { $ref: '#/components/schemas/TrackingTag' }
        '400': { description: 'Account platform not supported, or invalid `adAccountId`.' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
        '404': { description: Account not found or not accessible. }
        '405': { description: Platform does not support listing tracking tags. }

    post:
      operationId: createTrackingTag
      tags: [Tracking Tags]
      summary: Create a tracking tag
      description: |
        Creates a Meta Pixel on the given ad account (`POST /act_{id}/adspixels`
        — `name` is the only input). Returns the created tag including its
        install `code`. The pixel is owned by the Business Manager that owns the
        ad account; a pixel created on a personal (non-BM) ad account ends up
        with `ownerBusinessId: null` and can't be shared with other ad accounts.

        Creating a pixel does NOT install it — install the returned `code`
        snippet on the site, or send events server-side via
        `POST /v1/ads/conversions`. The check `installed` is derived from
        `lastFiredTime`.

        NOT idempotent: each call creates a new pixel. Do not retry blindly on
        timeout. Meta only (platform `metaads`); other platforms return 405.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string }, description: 'Meta ads SocialAccount id (platform `metaads`).' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [adAccountId, name]
              properties:
                adAccountId: { type: string, description: 'Meta ad account id, e.g. `act_123456789`.' }
                name: { type: string, minLength: 1, maxLength: 200 }
      responses:
        '201':
          description: Tracking tag created
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [metaads] }
                  tag: { $ref: '#/components/schemas/TrackingTag' }
        '400': { description: 'Invalid body, invalid `adAccountId`, over the per-business pixel cap, or ad account not in a Business Manager.' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
        '404': { description: Account not found or not accessible. }
        '405': { description: Platform does not support creating tracking tags. }

  /v1/accounts/{accountId}/tracking-tags/{tagId}:
    get:
      operationId: getTrackingTag
      tags: [Tracking Tags]
      summary: Get a tracking tag
      description: |
        Returns the full tag record including the base-code `code` snippet,
        `lastFiredTime`, `ownerBusinessId`, `isUnavailable`, etc. Meta only
        (platform `metaads`); other platforms return 405.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string } }
        - { name: tagId, in: path, required: true, schema: { type: string }, description: 'Pixel id.' }
      responses:
        '200':
          description: Tracking tag fetched
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [metaads] }
                  tag: { $ref: '#/components/schemas/TrackingTag' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
        '404': { description: Account or tracking tag not found. }
        '405': { description: Platform does not support fetching a tracking tag. }

    patch:
      operationId: updateTrackingTag
      tags: [Tracking Tags]
      summary: Update a tracking tag
      description: |
        Partial-update a pixel. Whitelisted fields: `name` (rename),
        `enableAutomaticMatching`, `automaticMatchingFields`,
        `firstPartyCookieStatus`, `dataUseSetting`. At least one is required.
        Returns the re-fetched canonical tag. Meta only (platform `metaads`);
        other platforms return 405.

        There is no DELETE — Meta has no API to delete a pixel. To stop using
        one, unshare it from your ad accounts (`DELETE
        .../tracking-tags/{tagId}/shared-accounts`) or disable it in Events
        Manager.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string } }
        - { name: tagId, in: path, required: true, schema: { type: string }, description: 'Pixel id.' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: At least one field is required; the route returns 400 if the body is empty.
              properties:
                name: { type: string, minLength: 1, maxLength: 200 }
                enableAutomaticMatching:
                  type: boolean
                  description: Meta Advanced Matching toggle (`enable_automatic_matching`).
                automaticMatchingFields:
                  type: array
                  description: |
                    Which user fields Advanced Matching may collect. Meta's
                    terse codes: em=email, ph=phone, fn=first name, ln=last
                    name, ge=gender, db=date of birth, ct=city, st=state,
                    zp=zip.
                  items:
                    type: string
                    enum: [em, ph, fn, ln, ge, db, ct, st, zp, country, external_id]
                firstPartyCookieStatus:
                  type: string
                  enum: [empty, first_party_cookie_disabled, first_party_cookie_enabled]
                dataUseSetting:
                  type: string
                  enum: [advertising_and_analytics, analytics_only, empty]
      responses:
        '200':
          description: Tracking tag updated (re-fetched canonical state)
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [metaads] }
                  tag: { $ref: '#/components/schemas/TrackingTag' }
        '400': { description: Invalid body (e.g. no fields supplied) or Meta validation failure. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
        '404': { description: Account or tracking tag not found. }
        '405': { description: Platform does not support updating tracking tags. }

  /v1/accounts/{accountId}/tracking-tags/{tagId}/shared-accounts:
    get:
      operationId: listTrackingTagSharedAccounts
      tags: [Tracking Tags]
      summary: List accounts it is shared with
      description: Meta only (platform `metaads`); other platforms return 405.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string } }
        - { name: tagId, in: path, required: true, schema: { type: string }, description: 'Pixel id.' }
      responses:
        '200':
          description: Shared ad accounts listed
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [metaads] }
                  sharedAccounts:
                    type: array
                    items: { $ref: '#/components/schemas/SharedAdAccount' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
        '404': { description: Account or tracking tag not found. }
        '405': { description: Platform does not support shared accounts. }

    post:
      operationId: addTrackingTagSharedAccount
      tags: [Tracking Tags]
      summary: Share with an ad account
      description: |
        Shares the pixel with another ad account so campaigns/audiences in that
        account can use it. Requires that you administer both the pixel's owning
        Business Manager and the target ad account; a pixel on a personal
        (non-BM) ad account can't be shared (Meta will reject the call). Meta
        only (platform `metaads`); other platforms return 405.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string } }
        - { name: tagId, in: path, required: true, schema: { type: string }, description: 'Pixel id.' }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [adAccountId]
              properties:
                adAccountId: { type: string, description: 'Ad account to share with, e.g. `act_123456789`.' }
      responses:
        '201':
          description: Tracking tag shared with the ad account
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [metaads] }
                  ok: { type: boolean }
        '400': { description: 'Invalid body / `adAccountId`, or Meta rejected the share (e.g. personal ad account).' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
        '404': { description: Account or tracking tag not found. }
        '405': { description: Platform does not support shared accounts. }

    delete:
      operationId: removeTrackingTagSharedAccount
      tags: [Tracking Tags]
      summary: Stop sharing with an account
      description: |
        `adAccountId` may be passed as a query parameter (recommended) or as a
        JSON body field for clients that can send DELETE bodies. Meta only
        (platform `metaads`); other platforms return 405.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string } }
        - { name: tagId, in: path, required: true, schema: { type: string }, description: 'Pixel id.' }
        - { name: adAccountId, in: query, required: false, schema: { type: string }, description: 'Ad account to unshare, e.g. `act_123456789`. May also be sent in the JSON body.' }
      responses:
        '204': { description: Ad account unshared (no content). }
        '400': { description: '`adAccountId` missing (neither query nor body), or Meta rejected the unshare.' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
        '404': { description: Account or tracking tag not found. }
        '405': { description: Platform does not support shared accounts. }

  /v1/accounts/{accountId}/tracking-tags/{tagId}/stats:
    get:
      operationId: getTrackingTagStats
      tags: [Tracking Tags]
      summary: Get aggregated event stats
      description: |
        Returns aggregated event counts for the pixel (`GET /{pixel_id}/stats`).
        Rows are passed through from Meta as-is — their shape depends on the
        `aggregation` requested. Meta only (platform `metaads`); other platforms
        return 405.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string } }
        - { name: tagId, in: path, required: true, schema: { type: string }, description: 'Pixel id.' }
        - name: aggregation
          in: query
          required: false
          schema:
            type: string
            default: event
            enum:
              - event
              - host
              - url
              - url_by_rule
              - pixel_fire
              - device_type
              - device_os
              - browser_type
              - had_pii
              - custom_data_field
              - match_keys
              - event_source
              - event_detection_method
              - event_processing_results
              - event_total_counts
              - event_value_count
          description: Aggregation dimension. Defaults to `event`.
        - { name: startTime, in: query, required: false, schema: { type: integer }, description: 'Unix seconds lower bound.' }
        - { name: endTime, in: query, required: false, schema: { type: integer }, description: 'Unix seconds upper bound.' }
      responses:
        '200':
          description: Stats fetched
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [metaads] }
                  stats:
                    type: object
                    properties:
                      aggregation: { type: string }
                      startTime: { type: integer }
                      endTime: { type: integer }
                      rows:
                        type: array
                        items: { type: object, additionalProperties: true }
        '400': { description: Invalid query parameter. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
        '404': { description: Account or tracking tag not found. }
        '405': { description: Platform does not support tracking-tag stats. }
