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 16 integrations. Schedule posts, retrieve analytics,
    manage DMs, comments, and reviews across Twitter/X, Instagram, WhatsApp, TikTok, LinkedIn,
    Facebook, YouTube, Threads, Reddit, Pinterest, Bluesky, Telegram, Google Business, Snapchat,
    Discord, and Slack, all from a single REST API. Run paid ads on Meta (Facebook + Instagram), Google,
    TikTok, LinkedIn, Pinterest, X, and OpenAI from the same account.

    Key features: Unified posting to 16 platforms, ads management on 7 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, Slack. Supported ad platforms: Meta Ads, Google Ads, TikTok Ads, LinkedIn Ads, Pinterest Ads, X Ads, OpenAI 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. Zernio also applies per-account daily publish caps as an anti-abuse safeguard (Instagram 100/day, Facebook 100/day, Threads 250/day, X/Twitter 50/day, Pinterest 25/day, 50/day for every other platform), plus a 25-posts-per-hour per-account velocity cap.

    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 entered the scheduled state: created with a schedule, added to a queue, a draft promoted to scheduled or queued, a failed post retried, or a recycled clone created
    - `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.platform.deleted` - A single platform target inside a post was detected as deleted on the platform after publishing (e.g. the user deleted the Instagram post natively). Detection is poll-driven via Zernio background sync (~hourly), not real-time. `platform.deletedAt` carries the detection time. Coverage is bounded to the posts the platform listing returns.
    - `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
    - `conversation.started` - A new conversation opened between one of your accounts and a contact, in either direction (any DM platform); fires only the first time the thread appears
    - `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, Slack, Instagram, Facebook Messenger)
    - `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".
    - `call.received` - An inbound call (phone/PSTN or WhatsApp) reached one of your numbers and was routed to its destination
    - `call.ended` - A call ended; carries duration, end reason, cost breakdown, and the recording when enabled
    - `call.failed` - A call failed with a hard error before or during bridging
    - `call.permission_request` - A WhatsApp user accepted or rejected your call-permission request
    - `whatsapp.automatic_event` - Meta's automatic event identification detected a lead or purchase in a Click-to-WhatsApp conversation. Branch on `eventName` (`LeadSubmitted` | `Purchase`); carries the `ctwa_clid` Conversions API match key
    - `whatsapp.number.kyc_submitted` - An end customer completed a hosted KYC share link; the number entered regulatory review under your account
    - `whatsapp.number.activated` - A provisioned WhatsApp number finished setup and is ready to connect
    - `whatsapp.number.declined` - A regulated number order was declined in review; nothing activates and nothing is billed
    - `whatsapp.number.action_required` - The regulator asked for more information on a placed number order; the order stays pending until provided
    - `whatsapp.number.verification_required` - A regulated number needs end-user ID verification; carries the link to forward
    - `whatsapp.number.suspended` - An active number was suspended (e.g. failed payment); carries a `reason`
    - `whatsapp.number.reactivated` - A suspended number is usable again
    - `whatsapp.number.released` - A number was released and is no longer usable (terminal); carries a `reason`
    - `verification.approved` - A managed-OTP verification was approved (the user submitted the correct code)
    - `verification.failed` - A managed-OTP verification was exhausted after too many wrong code attempts
    - `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. `GET /v1/usage` is dual-mode: bare calls return the
      plan / quota snapshot (back-compat, same as `/v1/usage-stats`); calls
      with `range` / `granularity` params return usage METERING — billed spend
      (USD) by product family (accounts, numbers, calls, sms, dlc, xApi,
      credits) over any window, from Metronome's invoice breakdown (also served
      at `GET /v1/usage/daily`). The `/v1/usage/<domain>` spokes (calls, sms)
      meter per-domain consumption volumes over a window. `GET /v1/billing` is
      the billing statement (balance, credits, caps, payment status).
  - 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.

      Resource groups. A key can opt out of any of the ten groups (publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks) via `disabledResourceGroups` at creation. Omit the field for a legacy full-access key. A key with any group disabled mints with the `zrk_` prefix, is refused on operations in those groups with 403 `insufficient_permissions` plus `required_group`, and can never manage API keys, invites, connected apps, or member identity. Each operation publishes its group as `x-resource-group`. There is no update endpoint: duplicate the key with different groups and revoke the old one.
  - name: Connected Apps
    description: |
      List and revoke the OAuth clients (AI assistants and MCP connectors) authorized
      on the account.

      Connector tokens resolve against the same resource-group registry as API keys, but there is no way to narrow a connector's groups yet. Treat an authorized connector as full account access and revoke it if that is not what you want.
  - 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: Ad Campaigns
    description: |
      The advertising structure: campaigns, ad sets, and ads. Create standalone campaigns/ads, boost an
      organic post, duplicate at any level, pause/resume, and read the full tree and timeline. Campaigns
      are virtual aggregations of ads grouped by their platform campaign ID.
      Per-operation platform support is shown by the platform badges. Requires the Ads add-on.
  - name: Ad Creatives
    description: |
      Creative assets: the standalone creative library (create/reuse/rename), the ad-account image library
      (list + base64 upload), rendered ad previews, and product catalogs for Advantage+/dynamic ads. Meta only.
      Requires the Ads add-on.
  - name: Ad Audiences
    description: |
      Custom audiences for targeting: customer lists (hashed upload), website + engagement + lookalike
      audiences, and reusable saved-targeting presets. Creation support varies by platform (badges show which);
      LinkedIn adds company-list and engagement segments.
      Requires the Ads add-on.
  - name: Ad Targeting
    description: |
      Targeting discovery: search interests/behaviors/geo/demographics, estimate reach, and (LinkedIn)
      bid pricing and supply forecasts. Feeds the targeting fields on create.
      Requires the Ads add-on.
  - name: Ad Insights
    description: |
      Measurement: cached aggregate analytics per ad/campaign, plus live Meta Graph insight queries
      (arbitrary fields, breakdowns, filtering, attribution windows) and async report runs.
      Requires the Ads add-on.
  - name: Conversions
    description: |
      Server-side Conversions API: send + adjust conversion events (with hashed matching and consent/LDU
      forwarding), read Event Match Quality, and manage conversion destinations (pixels/datasets) and their
      ad-account associations. Supported on Meta, Google, TikTok and LinkedIn (badges show per operation).
      Requires the Ads add-on.
  - name: Messaging Ads
    description: |
      Click-to-message and click-to-call destination ads: WhatsApp (CTWA), Messenger, Instagram Direct,
      and Call ads. Meta only.
      Requires the Ads add-on.
  - name: Reach and Frequency
    description: |
      Fixed-price reserved (Reach & Frequency) buying: quote a prediction, reserve price + inventory, and
      buy via a RESERVED campaign. Meta only.
      Requires the Ads add-on.
  - name: Lead Gen
    description: |
      Instant lead forms on Facebook Pages: create/list/archive forms and retrieve (or test) their leads.
      Meta only.
      Requires the Ads add-on.
  - name: Ad Accounts
    description: |
      Ad accounts and operational/diagnostic reads: list accounts, account finances, change/audit log,
      A/B studies, high-demand periods, ad labels, DSA defaults + recommendations, and Business Managers
      (Meta) / Business Centers (TikTok).
      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.platform.deleted, post.tiktok.url_resolved, post.external.created, post.external.updated, post.external.deleted, account.connected, account.disconnected, account.ads.initial_sync_completed, message.received, conversation.started, call.received, call.ended, call.failed, call.permission_request, 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.automatic_event, 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, verification.approved, verification.failed, webhook.test.
      Security: optional HMAC-SHA256 signature in X-Zernio-Signature header. Configure a secret key to enable verification. Custom headers supported.

      Resource groups. Every event belongs to one group: post.* to publishing, message.*, reaction.*, conversation.*, call.* and whatsapp.automatic_event to messages, comment.* and review.* to engagement, lead.* to contacts, ad.* to ads, account.* and whatsapp.template.* to accounts, whatsapp.number.* and verification.* to telephony, webhook.test to webhooks. Two independent controls use that mapping. (1) A restricted (zrk_) API key can only subscribe to, test-fire, redeliver, or read delivery logs for events in the groups it holds, so it can never create a subscription broader than itself. (2) Each subscription carries its own `disabledResourceGroups` denylist: events in a disabled group are dropped before delivery to that endpoint, on live delivery and on every replay path, no matter which key or session created the subscription. Changing that denylist applies to every event emitted after the change; events already queued when it landed can still be delivered for up to five minutes after they were enqueued, because the delivery worker trusts a five-minute enqueue-time snapshot before re-checking. Absent or empty on a subscription means it receives everything it subscribes to, which is the behavior of every subscription created before the field existed.
  - 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: Verify
    description: |
      Managed one-time passcodes (OTP) for phone verification. Two calls
      handle the whole lifecycle: create a verification (we generate the
      code, deliver it by SMS from a phone number on your account, and store
      only its hash) and check the code the user typed. Usage-based billing
      only.
  - 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 }
    IdempotencyKeyHeader:
      name: Idempotency-Key
      in: header
      required: false
      schema: { type: string, maxLength: 255 }
      description: >-
        Optional client-generated unique key (e.g. a UUID) that makes retries
        safe. Same key + same body replays the original response; same key +
        different body → 422; key still processing → 409.
  responses:
    IdempotencyKeyInFlight:
      description: Same Idempotency-Key still processing; retry after a short backoff
    IdempotencyKeyReused:
      description: Idempotency-Key reused with a different body
    BadRequest:
      description: Invalid request
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    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
    ResourceGroupForbidden:
      description: >-
        The API key is a restricted key (zrk_ prefix) and may not perform this
        operation. Three cases. (1) The operation's resource group (see the
        operation's x-resource-group) is disabled on the key: fix it by
        creating a key with the group enabled in the dashboard API keys tab
        and revoking the old one. (2) The operation is admin-plane
        (x-resource-group admin-plane: API keys, invites, connected apps,
        member identity), which is never grantable to restricted keys; the
        error reads "Restricted API keys cannot manage API keys, invites, or
        member identity." and the fix is a full-access key or the dashboard,
        never a new restricted key. (3) On webhook subscription writes,
        delivery-log reads and replays, a named event maps to a resource group
        the key does not hold, so a restricted key can never create or edit a
        subscription broader than itself (a no-messages key cannot subscribe
        to, test-fire, redeliver or read logs for message.* events).
      content:
        application/json:
          schema:
            type: object
            properties:
              error:
                type: string
                example: "This API key has the 'messages' resource group disabled. GET /api/v1/inbox/conversations requires it. Create a key with 'messages' enabled in the dashboard API keys tab."
              code:
                type: string
                enum: [insufficient_permissions, unclassified_resource]
              required_group:
                type: string
                description: 'The resource group the key needs for this operation. Absent on admin-plane and unclassified-path denials.'
                enum: [publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks]
    RateLimited:
      description: |
        The connected account's upstream platform quota is exhausted.

        Reddit rate-limits per connected Reddit user (1000 requests per
        10-minute window), and that budget is shared by every operation using
        that account. Retry after the window resets rather than retrying
        immediately; repeated calls while exhausted do not succeed and keep the
        budget spent.
      headers:
        Retry-After:
          description: 'Seconds remaining until the upstream quota resets.'
          schema: { type: integer }
      content:
        application/json:
          schema: { $ref: '#/components/schemas/ErrorResponse' }
    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 is on an enterprise
            contract with a negotiated connected-account cap and has
            reached it. Self-service teams have NO connection cap (the
            $1/account rate continues at any scale), so this reason can
            only fire for teams whose contract sets an explicit limit.
            `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) to adjust the contract's limit.
      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.
                  effective_account_limit:
                    type: integer
                    description: |
                      The negotiated connected-account cap from the
                      team's enterprise contract. Self-service teams
                      have no cap and never receive this reason. 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 reached the negotiated cap on its enterprise contract
              value:
                error: 'You have 2000 connected accounts, which reaches the 2,000-account limit on your contract. Contact us to raise it.'
                code: PAYMENT_REQUIRED
                reason: enterprise_required
                documentation_url: https://docs.zernio.com/billing/payment-method-required
                dashboard_url: https://zernio.com/enterprise
                details:
                  effective_account_limit: 2000
                  current_account_count: 2000
  schemas:
    Verification:
      type: object
      description: 'A managed OTP verification. The code itself is never returned or stored (hash only).'
      properties:
        id: { type: string }
        status: { type: string, enum: [pending, approved, expired, max_attempts_reached, canceled, delivery_failed] }
        channel: { type: string, enum: [sms] }
        to: { type: string }
        expiresAt: { type: string, format: date-time }
        attempts: { type: integer }
        maxAttempts: { type: integer }
        sendCount: { type: integer, description: 'Accepted deliveries (initial send + resends); each bills one verification fee.' }
        lastSentAt: { type: [string, "null"], format: date-time }
        createdAt: { type: string, format: date-time }
        resend: { type: boolean, description: 'Present on create responses: true when an active verification was resent instead of created.' }
    RfPrediction:
      type: object
      description: A Meta Reach & Frequency prediction. Money values in whole units of the ad account currency.
      properties:
        predictionId: { type: string }
        status: { type: string, description: "ready | pending | failed:<meta code>" }
        budget: { type: [number, "null"], description: "Quoted (or provided) lifetime budget for the window." }
        reach: { type: [integer, "null"], description: "Predicted (or requested) unique reach." }
        impressions: { type: [integer, "null"] }
        minBudget: { type: [number, "null"], description: "Meta's allowed lower bound for this spec." }
        maxBudget: { type: [number, "null"] }
        minReach: { type: [integer, "null"] }
        maxReach: { type: [integer, "null"] }
        frequencyCap: { type: [integer, "null"] }
        startTime: { type: [integer, "null"], description: "Unix seconds; the reserved window the R&F ad set will run on." }
        stopTime: { type: [integer, "null"] }
        expiresAt: { type: [string, "null"], description: "When the reservation's locked price expires (set after reserving)." }
    CtwaAdRequestBody:
      type: object
      required: [accountId, adAccountId, name]
      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.
        adSetId:
          type: string
          description: |
            Attach the creatives to this EXISTING messaging ad set instead of
            building a campaign, so the ad set keeps its learning phase. It then
            owns budget, targeting and schedule, so `budgetAmount`, `budgetType`,
            `endDate`, `objective`, `countries`, `interests` and `audienceId` are
            rejected with a 400 alongside it. Its `destination_type` must match
            the ad's destination.
        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.
            Required unless `adSetId` is set, where the ad set owns it.
        budgetType:
          type: string
          enum: [daily, lifetime]
          description: Required unless `adSetId` is set.
        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. Each entry maps to Meta's
            TargetingGeoLocationCity. `key` is Meta's city ID. `radius`
            and `distance_unit` are coupled: set both or neither.
            Meta enforces a minimum city radius (~17 km / 10 mi);
            smaller values resolve to a 0-size audience and the ad
            fails at launch. For a tighter catchment use customLocations
            (lat/lng).
          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.
        placements:
          type: object
          description: |
            Manual ad placements on the shared ad set. Omit
            for automatic placements. 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 and restricts which
            placements are eligible for click-to-WhatsApp ads, returning an actionable
            error which we surface.
          properties:
            publisherPlatforms:
              type: array
              items: { type: string, enum: [facebook, instagram, threads, messenger, audience_network, whatsapp] }
              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."
        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`. `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: |
            Legal entity that benefits from the ad. Required when targeting EU users
            (EU DSA, Article 26). Optional if the ad account has a default beneficiary:
            set it once via `PATCH /v1/ads/accounts` or in Meta Ads Manager, and Meta
            fills it in whenever the field is omitted.
        dsaPayor:
          type: string
          maxLength: 100
          description: |
            Legal entity that pays for the ad. Can differ from `dsaBeneficiary`
            (for example, an agency paying for a client's ads). Same rules as
            `dsaBeneficiary`: required for EU targeting unless the ad account has
            a default payor.
    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|openrouter, 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).
    CommentAutomationAudience:
      type: object
      description: |
        Who a comment automation answers. Instagram only - Meta exposes the follow
        relationship on no other platform, and only for people who have MESSAGED the
        account (a comment grants no consent). `whenUnknown` is therefore the important
        setting: it decides what happens for a first-time commenter.
      properties:
        followerStatus:
          type: string
          enum: [any, follower, non_follower]
          default: any
        minFollowerCount:
          type: integer
          minimum: 0
          description: 'Skip commenters with fewer followers than this. Omit for no size rule.'
        whenUnknown:
          type: string
          enum: [send, skip, verify]
          default: send
          description: |
            What to do when Instagram will not reveal the follow relationship.
              * `send` (default) - deliver the DM anyway (fails open).
              * `skip` - stay silent.
              * `verify` - send `followGate.message` with a confirm button. Tapping it is a
                message, which grants consent, so the re-check on the tap resolves and the
                real DM (or `followGate.notFollowingMessage`) follows automatically.
    CommentAutomationFollowGate:
      type: object
      description: 'Copy for the follow gate. Sensible defaults are used for any field left empty.'
      properties:
        message:
          type: string
          maxLength: 640
          description: 'Confirmation DM sent when whenUnknown=verify.'
        buttonLabel:
          type: string
          maxLength: 20
          description: 'Confirm button label. Defaults to "I''m following".'
        notFollowingMessage:
          type: string
          maxLength: 1000
          description: 'Sent to a commenter we know does not follow (followerStatus=follower). Omit to stay silent on a keyword comment; a confirm tap always gets an answer.'
    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)" }
    CommentAutomationTemplate:
      type: object
      description: |
        A Meta generic template (product card) sent as the automation's first DM.
        It REPLACES the plain `dmMessage` bubble: a Meta message carries one body
        shape, and a comment gets exactly one private reply, so the card and the
        text cannot both be delivered. Put your selling copy in `subtitle`.
        Mutually exclusive with `buttons` (sending both is a 400). Works on both
        the `comment` and `story_reply` triggers.
        Up to 10 elements, rendered as a horizontally swipeable carousel.
        Rendering confirmed on the Instagram and Messenger mobile apps.
      required: [type, elements]
      properties:
        type: { type: string, enum: [generic] }
        elements:
          type: array
          minItems: 1
          maxItems: 10
          items: { $ref: '#/components/schemas/CommentAutomationTemplateElement' }
    CommentAutomationTemplateElement:
      type: object
      required: [title]
      properties:
        title: { type: string, maxLength: 80, description: 'Card headline (80 chars max). Also used as the Inbox preview for the sent DM.' }
        subtitle: { type: string, maxLength: 80, description: 'Card description, e.g. the price or a short pitch (80 chars max).' }
        imageUrl: { type: string, format: uri, description: 'Publicly reachable http(s) image rendered large above the card.' }
        buttons:
          type: array
          maxItems: 3
          description: 'Up to 3 card buttons. A generic template has NO phone button, on either platform. `url` buttons are click-tracked when linkTracking is on.'
          items:
            type: object
            required: [type, title]
            properties:
              type: { type: string, enum: [url, postback] }
              title: { type: string, maxLength: 20 }
              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)' }
    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) and otp (omit it and WhatsApp supplies its own label, localized to the template language; an English label on a non-English template is rejected).'
        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'
        - $ref: '#/components/schemas/WhatsAppCarouselComponent'
        - $ref: '#/components/schemas/WhatsAppLimitedTimeOfferComponent'
      discriminator:
        propertyName: type
        mapping:
          header: '#/components/schemas/WhatsAppHeaderComponent'
          body: '#/components/schemas/WhatsAppBodyComponent'
          footer: '#/components/schemas/WhatsAppFooterComponent'
          buttons: '#/components/schemas/WhatsAppButtonsComponent'
          carousel: '#/components/schemas/WhatsAppCarouselComponent'
          limited_time_offer: '#/components/schemas/WhatsAppLimitedTimeOfferComponent'
    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_text_named_params:
              type: array
              items:
                $ref: '#/components/schemas/WhatsAppNamedParamExample'
              description: 'Sample values for NAMED header variables (templates using {{customer_name}}-style tokens with parameter_format: NAMED).'
            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)
            body_text_named_params:
              type: array
              items:
                $ref: '#/components/schemas/WhatsAppNamedParamExample'
              description: 'Sample values for NAMED body variables (templates using {{customer_name}}-style tokens with parameter_format: NAMED).'
    WhatsAppNamedParamExample:
      type: object
      required: [param_name, example]
      properties:
        param_name:
          type: string
          pattern: '^[a-z0-9_]+$'
          description: 'Variable name as it appears in the text, without braces (e.g. customer_name for {{customer_name}}).'
        example:
          type: string
          description: Sample value for this variable.
    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'
    WhatsAppCarouselComponent:
      type: object
      required: [type, cards]
      properties:
        type:
          type: string
          enum: [carousel]
        cards:
          type: array
          minItems: 2
          maxItems: 10
          description: '2-10 cards. Meta requires all cards to share the same component structure; a mismatch surfaces as a rejected_reason. MARKETING category only.'
          items:
            type: object
            required: [components]
            properties:
              components:
                type: array
                minItems: 1
                description: 'Per-card components. Each card carries its own media header + optional body + up to 2 buttons. Footer and nested carousel are not allowed inside cards.'
                items:
                  $ref: '#/components/schemas/WhatsAppCarouselCardComponent'
    WhatsAppCarouselCardComponent:
      oneOf:
        - $ref: '#/components/schemas/WhatsAppHeaderComponent'
        - $ref: '#/components/schemas/WhatsAppBodyComponent'
        - $ref: '#/components/schemas/WhatsAppButtonsComponent'
      discriminator:
        propertyName: type
        mapping:
          header: '#/components/schemas/WhatsAppHeaderComponent'
          body: '#/components/schemas/WhatsAppBodyComponent'
          buttons: '#/components/schemas/WhatsAppButtonsComponent'
    WhatsAppLimitedTimeOfferComponent:
      type: object
      required: [type, limited_time_offer]
      properties:
        type:
          type: string
          enum: [limited_time_offer]
        limited_time_offer:
          type: object
          required: [text]
          properties:
            text:
              type: string
              minLength: 1
              maxLength: 16
              description: 'Short offer label (<=16 chars). MARKETING only.'
            has_expiration:
              type: boolean
              description: 'Toggles the countdown timer. The actual coupon code + expiry are bound at SEND time, not here.'
    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, enum: [SPICINESS_UNSPECIFIED, MILD, MEDIUM, HOT], 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, minimum: 1, 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
          minItems: 1
          items: { $ref: '#/components/schemas/FoodMenuLabel' }
        attributes: { $ref: '#/components/schemas/FoodMenuItemAttributes' }
        options:
          type: array
          items:
            type: object
            required: [labels, attributes]
            properties:
              labels:
                type: array
                minItems: 1
                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
          minItems: 1
          items: { $ref: '#/components/schemas/FoodMenuLabel' }
        items:
          type: array
          items: { $ref: '#/components/schemas/FoodMenuItem' }
    FoodMenu:
      type: object
      required: [labels]
      properties:
        labels:
          type: array
          minItems: 1
          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
          format: uri
          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
        provisionalSince:
          type: string
          format: date
          description: 'Present only when the range reaches into YouTube''s ~3-day processing window: the first date whose numbers are provisional and may still be revised by YouTube.'
        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
        provisionalSince:
          type: string
          format: date
          description: 'Present only when the range reaches into YouTube''s ~3-day processing window: the first date whose numbers are provisional and may still be revised by YouTube.'
        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}).

            Monetary metrics additionally carry "unit" and "currency". Zernio never rescales money:
            "total" and every "values[].value" are the platform's raw numbers in the stated unit.
            Monetary metrics also keep "values" on metricType=total_value, because their "total" is the
            sum of the daily buckets the platform returned over the range: keep the series so you can
            reconcile that sum against the platform's own reporting before invoicing on it.
            A metric that could not be served is absent from this object and listed in
            "unavailableMetrics" instead, so an unavailable metric is never reported as a zero.
          additionalProperties:
            type: object
            properties:
              total:
                type: number
                description: Sum or aggregate value for the metric
              values:
                type: array
                description: Daily values (for time_series, and always on monetary metrics)
                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
              unit:
                type: string
                enum: [micro_amount, unspecified]
                description: |
                  Present on monetary metrics only. The scale of "total" and of every "values[].value",
                  exactly as the platform returned them.

                  "micro_amount": the platform returned an object shape carrying a micro amount, and the
                  values are that integer, summed, unconverted. Zernio does not publish a divisor because
                  Meta does not document one; divide by the scale you have verified against the Page's own
                  Meta Business Suite export. On Facebook Page insights this is always
                  content_monetization_earnings.

                  "unspecified": the platform returned a bare number with no unit metadata. It is passed
                  through as-is; the platform does not state whether it is major or minor currency units.
                  On Facebook Page insights this is always monetization_approximate_earnings.
              currency:
                type: [string, "null"]
                description: |
                  ISO 4217 currency of a monetary metric, or null when the platform omitted it.
                  Always null on monetization_approximate_earnings, which Meta returns as a bare number
                  with no currency; always present on content_monetization_earnings.
                example: "USD"
        unavailableMetrics:
          type: array
          description: |
            Requested metrics that could not be served. Present only when at least one metric is
            unavailable, and absent otherwise. Each listed metric is OMITTED from "metrics" rather than
            reported as 0, which is how an unavailable metric is distinguished from a genuine zero.
            The request itself still succeeds with HTTP 200.
          items:
            type: object
            properties:
              metric:
                type: string
                description: The requested metric name.
              reason:
                type: string
                enum: [not_enrolled, permission_missing, unsupported_metric, no_data, unreadable_value, mixed_currency, upstream_error]
                description: |
                  "not_enrolled": the account is not enrolled in the program behind this metric.
                  "permission_missing": the connected user lacks access to this metric.
                  "unsupported_metric": the platform does not accept this metric name on the API version Zernio uses.
                  "no_data": the platform returned no bucket for this metric over the requested range.
                  "unreadable_value": the platform returned a value shape Zernio cannot read, so no total is reported.
                  "mixed_currency": readable values disagree on currency or unit within the range.
                  "upstream_error": any other platform failure.

                  "no_data" is the common case in practice. The others are defensive: "not_enrolled" and
                  "unsupported_metric" in particular have not been observed on live Facebook traffic, since
                  a non-enrolled Page returns zeros rather than an error and metric names are validated
                  before any platform call.
              message:
                type: string
                description: 'Platform-provided explanation when available (access tokens redacted), otherwise Zernio copy.'
        dataDelay:
          type: string
          example: "Data may be delayed up to 48 hours"
    FacebookPostEarningsResponse:
      type: object
      description: |
        Lifetime monetization earnings for one Facebook post. Same "unit" / "currency" contract and
        same unavailable-vs-zero contract as the Page-level response; there is no date range, no
        metricType, and no daily "values", because the single lifetime bucket IS the total.
      properties:
        success:
          type: boolean
          example: true
        accountId:
          type: string
          example: "64e1a2b3c4d5e6f7a8b9c0d1"
        postId:
          type: string
          description: 'The platform post ID that was queried, echoed back.'
          example: "123456789_987654321"
        platform:
          type: string
          example: "facebook"
        period:
          type: string
          enum: [lifetime]
          description: |
            Always "lifetime": the total is cumulative since publication and must not be summed
            across dates or across posts.
        metrics:
          type: object
          description: |
            One entry per served metric. A metric reported here with "total": 0 genuinely earned
            nothing (or its Page is not enrolled, which Meta reports identically).
          additionalProperties:
            type: object
            properties:
              total:
                type: number
                description: 'Lifetime earnings in "unit", exactly as Meta returned them. Never rescaled.'
              unit:
                type: string
                enum: [micro_amount, unspecified]
                description: |
                  "micro_amount": Meta returned an object shape carrying a micro amount, and "total" is
                  that integer, unconverted. Zernio does not publish a divisor because Meta does not
                  document one; divide by the scale you have verified against the Page's own Meta
                  Business Suite export. This is always content_monetization_earnings.

                  "unspecified": Meta returned a bare number with no unit metadata, passed through as-is;
                  Meta does not state whether it is major or minor currency units. This is always
                  monetization_approximate_earnings.
              currency:
                type: [string, "null"]
                description: |
                  ISO 4217 currency, or null when Meta omitted it. Always null on
                  monetization_approximate_earnings; always present on content_monetization_earnings.
                example: "USD"
        unavailableMetrics:
          type: array
          description: |
            Requested metrics Meta could not serve. Present only when at least one metric is
            unavailable, and absent otherwise. Each listed metric is OMITTED from "metrics" rather than
            reported as 0. The request itself still succeeds with HTTP 200.
          items:
            type: object
            properties:
              metric:
                type: string
                description: The requested metric name.
              reason:
                type: string
                enum: [not_enrolled, permission_missing, unsupported_metric, no_data, unreadable_value, mixed_currency, upstream_error]
                description: |
                  "not_enrolled": the account is not enrolled in the program behind this metric.
                  "permission_missing": the connected user lacks access to this metric.
                  "unsupported_metric": Meta does not accept this metric name on the API version Zernio uses.
                  "no_data": Meta returned no bucket for this metric.
                  "unreadable_value": Meta returned a value shape Zernio cannot read, so no total is reported.
                  "mixed_currency": readable values disagree on currency or unit.
                  "upstream_error": any other platform failure.

                  "no_data" is the common case in practice; the others are defensive.
              message:
                type: string
                description: 'Platform-provided explanation when available (access tokens redacted), otherwise Zernio copy.'
        dataDelay:
          type: string
    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"
        videoId:
          type: string
          description: Present only when demographics are scoped to a single video
        title:
          type: [string, "null"]
          description: 'Video title (video mode only)'
        publishedAt:
          type: [string, "null"]
          format: date-time
          description: 'Video publish date (video mode only)'
        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"
        provisionalSince:
          type: string
          format: date
          description: 'Present only when the range reaches into YouTube''s ~3-day processing window: the first date whose numbers are provisional and may still be revised by YouTube.'
        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.platform.deleted, post.tiktok.url_resolved, post.external.created, post.external.updated, post.external.deleted, account.connected, account.disconnected, account.ads.initial_sync_completed, message.received, conversation.started, call.received, call.ended, call.failed, call.permission_request, 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.automatic_event, 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, verification.approved, verification.failed]
          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
        disabledResourceGroups:
          type: array
          items:
            type: string
            enum: [publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks]
          description: >-
            Resource groups this subscription does not receive (opt-out
            denylist, same vocabulary and same semantics as the field on API
            keys). Absent or empty means the subscription receives every event
            listed in `events`, which is how every subscription created before
            this field existed behaves. An event whose group is listed here is
            dropped before delivery even when it is still present in `events`,
            and the same check runs on every replay path (test fire,
            redelivery, dead-letter requeue). Editing the denylist applies to
            every event emitted afterwards; events already queued when the edit
            landed can still be delivered for up to five minutes after they
            were enqueued.
    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
            metadata:
              type: object
              additionalProperties: true
              description: The free-form `metadata` object supplied when the post was created, echoed back so you can map events onto your own records. Omitted when the post was created without it.
        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`, for
        `post.platform.deleted` (same shape, fired when Zernio's background
        sync detects that a platform target published through Zernio was
        later deleted on the platform; poll-driven ~hourly, not real-time),
        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.platform.deleted, 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 }
            metadata:
              type: object
              additionalProperties: true
              description: The free-form `metadata` object supplied when the post was created, echoed back so you can map events onto your own records. Omitted when the post was created without it.
        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, deleted]
              description: Terminal status this event fires on. Matches the event suffix.
            platformPostId:
              type: string
              description: 'Platform-native post id. Present on `published` and `deleted`, 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) and on `deleted` (when one was recorded at publish time).'
            error:
              type: string
              description: Error message from the platform. Present on `failed` only.
            deletedAt:
              type: string
              format: date-time
              description: 'When the platform-side deletion was detected by Zernio sync (ISO 8601). Present only on `post.platform.deleted`.'
        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"]
                instagramProfile:
                  type: object
                  description: |
                    Instagram only, best-effort. Present ONLY for commenters who have
                    messaged the account before: Meta gates the follow relationship behind
                    messaging consent, and commenting does not grant it. Absent otherwise -
                    treat a missing object as "unknown", never as "not a follower". To check
                    on demand, call GET /v1/accounts/{accountId}/follow-status/{userId}.
                  properties:
                    isFollower:
                      type: [boolean, "null"]
                      description: The commenter follows this account.
                    isFollowing:
                      type: [boolean, "null"]
                      description: This account follows the commenter.
                    followerCount:
                      type: [integer, "null"]
                    isVerified:
                      type: [boolean, "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: |
                  Where to fetch the attachment. The contract depends on direction and
                  platform: inbound WhatsApp media points at the authenticated
                  `GET /v1/whatsapp/media/{mediaId}` and requires
                  `Authorization: Bearer <your API key>`, while outgoing media carries the
                  URL originally supplied and Instagram / Facebook / Telegram carry direct
                  platform CDN links that need no authentication.
              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, Slack, Instagram, Facebook Messenger)'
      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: 'Whoever added or removed the reaction. Usually the participant, but on Slack, Instagram and Facebook Messenger it is the business own platform id when the reaction was made from the native app: compare it with conversation.participantId.'
              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: |
                      Where to fetch the attachment. **The contract differs by platform.**

                      - **WhatsApp**: points at `GET /v1/whatsapp/media/{mediaId}`, an
                        authenticated Zernio endpoint. You MUST send
                        `Authorization: Bearer <your API key>`; fetching it without that
                        header returns `401`. Download and store the bytes when this
                        webhook arrives: Meta drops inbound media after a limited
                        retention window, after which the endpoint answers `400`
                        permanently and the media is unrecoverable.
                      - **Instagram / Facebook / Telegram**: a direct platform CDN link
                        that needs no authentication and expires on the platform's own
                        schedule.
                  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: 'Platform-specific message context (present when the message is a quick reply tap, postback button tap, inline keyboard callback, or a quote-reply to an earlier message)'
          properties:
            quotedMessageId:
              type: string
              description: |
                platformMessageId of the message this one is a quote-reply to.
                WhatsApp (`context.id`), Instagram and Facebook Messenger
                (`reply_to.mid`). On `message.sent` echoes (operator replied
                from the native app) this is the only metadata field populated.
            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.
            order:
              type: object
              description: |
                WhatsApp only. Cart submitted by the user from a commerce message
                (catalog, product, or product-list message). Meta's `order` object
                forwarded verbatim.
              properties:
                catalog_id:
                  type: string
                  description: Meta catalog the ordered products belong to.
                text:
                  type: string
                  description: Optional free-text note the user attached to the cart.
                product_items:
                  type: array
                  items:
                    type: object
                    properties:
                      product_retailer_id:
                        type: string
                        description: Retailer ID (SKU) of the ordered product.
                      quantity:
                        type: integer
                        description: Quantity ordered for this line item.
                      item_price:
                        type: number
                        description: Unit price of the item.
                      currency:
                        type: string
                        description: ISO 4217 currency code (e.g. USD).
            referredProduct:
              type: object
              description: |
                WhatsApp only. The product the user is asking about. Set when an
                inbound text carries Meta's `context.referred_product` (the user
                tapped "Message business" on a product). Forwarded verbatim.
              properties:
                catalog_id:
                  type: string
                  description: Meta catalog the product belongs to.
                product_retailer_id:
                  type: string
                  description: Retailer ID (SKU) of the product being asked about.
            contacts:
              type: array
              description: |
                WhatsApp only. Contact cards the user shared, forwarded verbatim
                from Meta. Read `contactsOrigin` before treating any number here
                as the sender's own.
              items:
                type: object
                additionalProperties: true
            contactsOrigin:
              type: string
              enum: [contact_request, other]
              description: |
                WhatsApp only. How the contact card was shared.
                `contact_request` means the user tapped a `request_contact_info`
                button, so the number is their own and consented. `other` means
                they picked a card from their address book: it may be anyone's,
                and must NOT be stored as the sender's identity. Omitted when
                Meta sends no origin.
            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, or from the WhatsApp Business app on Coexistence numbers)
      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: |
                      Where to fetch the attachment. For outgoing messages this is the
                      media URL as sent, so for WhatsApp it is the URL you supplied when
                      publishing (WhatsApp sends media by link), not a Zernio endpoint,
                      and it needs no Zernio credentials. Contrast the inbound direction:
                      `message.received` attachment URLs on WhatsApp point at the
                      authenticated `GET /v1/whatsapp/media/{mediaId}`.
                  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
            source:
              type: string
              enum: [whatsapp_business_app, cloud_api]
              description: 'WhatsApp send origin. whatsapp_business_app when sent from the WhatsApp Business phone app on a Coexistence number; cloud_api when sent through Zernio (dashboard, API, or broadcasts). Absent on non-WhatsApp platforms. This is not the inbox metadata.source lineage field.'
        conversation:
          $ref: '#/components/schemas/InboxWebhookConversation'
        account:
          $ref: '#/components/schemas/InboxWebhookAccount'
        timestamp:
          type: string
          format: date-time
    InboxMessageEditAttachment:
      type: object
      description: Attachment snapshot inside an edit-history entry.
      properties:
        type: { type: string }
        url: { type: string }
        payload: { type: object }
    InboxMessageEditHistoryEntry:
      type: object
      description: One prior version of an edited message.
      required: [text, attachments, editedAt]
      properties:
        text:
          type: [string, "null"]
        attachments:
          type: array
          items:
            $ref: '#/components/schemas/InboxMessageEditAttachment'
        editedAt:
          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:
            $ref: '#/components/schemas/InboxMessageEditHistoryEntry'
        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, SMS). 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, sms, slack]
            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
    DiscordGuildMember:
      type: object
      description: A Discord guild member, returned verbatim from Discord's API.
      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 }
    DiscordRole:
      type: object
      description: A Discord guild role, returned verbatim from Discord's API.
      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 }

    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, regular video uploads, and LinkedIn video posts. 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'
            - $ref: '#/components/schemas/SlackPlatformData'
          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
        removedFromPlatformAt:
          type: [string, "null"]
          format: date-time
          description: >-
            Set when a post that was successfully published later disappears
            from the platform (deleted on-platform or taken down by the
            platform). status stays "published" (it reflects the publish
            outcome); poll this field to detect post-publish removals. Absent
            while the post is live, and cleared if the post reappears.
            Detection runs with the analytics sync, so expect up to a few
            hours of lag.
        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. X only permits replying to your own posts or posts you are mentioned in; replying to an arbitrary other account's post is rejected by X.
        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:
        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 facebookSettings.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'
        facebookSettings:
          $ref: '#/components/schemas/FacebookSettings'
      description: >
        Feed posts support up to 10 images (no mixed video+image). Stories require single media (24h, no captions).
        Reels require single vertical video (9:16, 3-60s). Geo-restriction is a hard visibility
        restriction: users outside the specified countries cannot see the post. Not supported for stories.
        Draft, carousel, and colored-background text options live under facebookSettings, see FacebookSettings.

    FacebookSettings:
      type: object
      description: >
        Facebook options that must be nested under platformSpecificData.facebookSettings, or sent
        at the request root as facebookSettings. The remaining Facebook options sit directly on
        platformSpecificData, see FacebookPlatformData.
      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
        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.
        textFormatPresetId:
          type: string
          pattern: '^\d+$'
          description: >
            Facebook-defined preset ID that renders the post as large text on a colored
            background (Graph `text_format_preset_id`). Supply the raw numeric ID from Meta;
            we do not publish a catalog of presets and Facebook may change the available set.
            Pages only (ignored on personal profiles and groups) and text-only feed posts only:
            the request is rejected with 400 when mediaItems or carouselCards are present, when
            contentType is story or reel, or when content is empty. An attachment makes Facebook
            drop the background silently, so those are rejected up front. Length is NOT rejected:
            Facebook's composer stops offering a background at around 130 characters, but Meta
            documents no API limit, so longer content publishes and returns a warning instead.
            A URL detected in the content is NOT attached as a link preview while a preset is
            set, because a link attachment also makes Facebook drop the background.

    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 by username. The tag shape depends on the media: photos require x/y coordinates, Reels and videos take username only (coordinates are ignored), stories accept optional coordinates. For carousels, use mediaIndex to target specific slides (defaults to 0); video slides take username-only tags. Photo tags without valid coordinates are skipped.'
          items:
            type: object
            required: [username]
            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). Required for photos, ignored for Reels/videos, optional for stories.'
                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). Required for photos, ignored for Reels/videos, optional for stories.'
                example: 0.5
              mediaIndex:
                type: integer
                minimum: 0
                description: Zero-based index of the carousel item to tag. Defaults to 0. Tags on 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.

    MoneyAmount:
      type: object
      required: [amount, currencyCode]
      properties:
        amount:       { type: string, description: "Amount as a decimal string, e.g. \"88.59\"." }
        currencyCode: { type: string, description: "ISO 4217 currency code, e.g. \"USD\"." }

    LinkedInAdsPlatformData:
      type: object
      additionalProperties: false
      properties:
        costType:
          type: string
          enum: [CPM, CPC, CPV]
          description: |
            Campaign cost model (billing event). Defaults to `CPM`. Required when
            `unitCost` is set so the manual bid applies to an explicit cost model.
        unitCost:
          type: number
          description: |
            Manual bid in WHOLE account-currency units (e.g. 2.5 = $2.50). Requires
            `costType`. Omit for LinkedIn's automated (max delivery) bidding.
            LinkedIn enforces its own per-audience min/max bid bounds.
        optimizationTargetType:
          type: string
          description: |
            Campaign `optimizationTargetType` (e.g. `MAX_CLICK`, `TARGET_COST_PER_CLICK`,
            `MAX_IMPRESSION`). Forwarded verbatim, LinkedIn validates compatibility with
            the objective and `costType`. Omit for the objective-derived default:
            `awareness` gets `MAX_IMPRESSION`, `video_views` gets `MAX_VIDEO_VIEW`, and
            every other goal gets `MAX_CLICK`. `lead_generation` and `conversions` also
            get `MAX_CLICK`, because `MAX_LEAD` and `MAX_CONVERSION` need a lead gen form
            or a conversion rule that neither creation flow attaches. The default applies
            only to `SPONSORED_UPDATES` campaigns (every boost, and the image, video and
            carousel standalone ads), never to the `TEXT_AD`, `DYNAMIC` and
            `SPONSORED_INMAILS` campaigns the other creative formats produce. It is also
            skipped when `unitCost` or a non-`CPM` `costType` is set, since those select
            manual bidding and the bid is then yours to choose.
        creativeSelection:
          type: string
          enum: [OPTIMIZED, ROUND_ROBIN]
          description: How LinkedIn rotates creatives within the campaign. Defaults to `OPTIMIZED`.
        audienceExpansionEnabled:
          type: boolean
          description: Enable LinkedIn audience expansion. Defaults to false.
        offsiteDeliveryEnabled:
          type: boolean
          description: Deliver on the LinkedIn Audience Network. Defaults to false.
        connectedTelevisionOnly:
          type: boolean
          description: Restrict delivery to Connected TV inventory.
        carousel:
          type: object
          additionalProperties: false
          required: [cards]
          description: |
            POST /v1/ads/create only. Carousel ad with 2-10 image cards.
            Mutually exclusive with the other creative sources.
          properties:
            cards:
              type: array
              minItems: 2
              maxItems: 10
              items:
                type: object
                additionalProperties: false
                required: [imageUrl]
                properties:
                  imageUrl: { type: string, format: uri }
                  headline:
                    type: string
                    description: Card title. Falls back to the ad-level headline.
                  landingUrl:
                    type: string
                    format: uri
                    description: |
                      Per-card click destination. LinkedIn requires one on every
                      card; the ad-level `linkUrl` backfills cards that omit it.
        document:
          type: object
          additionalProperties: false
          required: [url, title]
          description: |
            POST /v1/ads/create only. Document ad rendered as an in-feed viewer.
            PDF, PPT or DOC up to 100MB. Mutually exclusive with the other
            creative sources.
          properties:
            url: { type: string, format: uri }
            title:
              type: string
              description: Document title.
        spotlight:
          type: object
          additionalProperties: false
          required: [headline, callToAction, landingUrl]
          description: |
            POST /v1/ads/create only. Dynamic Spotlight Ad personalized with the
            viewer's profile photo. Supported goals: traffic, awareness. logoUrl
            and organizationName default to the Company Page's; set them
            explicitly if LinkedIn rejects the create with a 404. Mutually
            exclusive with the other creative sources.
          properties:
            headline: { type: string }
            description:
              type: string
              description: Mutually exclusive with backgroundImageUrl.
            callToAction:
              type: string
              description: Button label text.
            landingUrl: { type: string, format: uri }
            logoUrl: { type: string, format: uri }
            organizationName: { type: string }
            showMemberProfilePhoto: { type: boolean, description: Defaults to true. }
            backgroundImageUrl:
              type: string
              format: uri
              description: Custom background. Replaces the description and the profile photo.
        follower:
          type: object
          additionalProperties: false
          required: [headline, description, callToAction]
          description: |
            POST /v1/ads/create only. Dynamic Follower Ad promoting the Company
            Page. Supported goals: engagement, awareness. headline and
            description take exactly one of preApproved or custom. Mutually
            exclusive with the other creative sources.
          properties:
            headline:
              type: object
              additionalProperties: false
              properties:
                preApproved:
                  type: string
                  description: LinkedIn preset id, not reviewed. Example GROW_YOUR_BUSINESS_INSIGHTS.
                custom:
                  type: string
                  description: Free text, reviewed by LinkedIn.
            description:
              type: object
              additionalProperties: false
              properties:
                preApproved:
                  type: string
                  description: LinkedIn preset id, not reviewed. Example GET_LATEST_JOBS_AND_INDUSTRY_NEWS.
                custom:
                  type: string
                  description: Free text, reviewed by LinkedIn.
            callToAction:
              type: string
              enum: [VISIT_ORGANIZATION_COMPANY_PAGE, VISIT_ORGANIZATION_LIFE_PAGE, VISIT_ORGANIZATION_JOBS_PAGE, VISIT_ORGANIZATION_CAREERS_PAGE]
            logoUrl: { type: string, format: uri }
            organizationName: { type: string }
            showMemberProfilePhoto: { type: boolean, description: Defaults to true. }
        jobs:
          type: object
          additionalProperties: false
          required: [headline, buttonLabel]
          description: |
            POST /v1/ads/create only. Dynamic Jobs Ad promoting your open roles,
            personalized with the viewer's profile photo. Requires goal
            job_applicants and a Company Page with active job postings.
            headline and buttonLabel take exactly one of
            preApproved or custom. logoUrl and organizationName default to the
            Company Page's. Mutually exclusive with the other creative sources.
          properties:
            headline:
              type: object
              additionalProperties: false
              properties:
                preApproved:
                  type: string
                  description: LinkedIn preset id, not reviewed. Example MEMBER_READY_FOR_YOUR_DREAM_JOB.
                custom:
                  type: string
                  description: Free text, reviewed by LinkedIn.
            buttonLabel:
              type: object
              additionalProperties: false
              properties:
                preApproved:
                  type: string
                  description: LinkedIn preset id, not reviewed. One of SEE_MORE_JOBS, VIEW_MORE, CAREERS_AT_COMPANY.
                custom:
                  type: string
                  description: Free text, reviewed by LinkedIn.
            logoUrl: { type: string, format: uri }
            organizationName: { type: string }
            showMemberProfilePhoto: { type: boolean, description: Defaults to true. }
        textAd:
          type: object
          additionalProperties: false
          required: [headline, description, landingUrl]
          description: |
            POST /v1/ads/create only. Classic right-rail Text Ad. The copy lives
            here; ad-level body and headline are not used. Mutually exclusive
            with the other creative sources.
          properties:
            headline:
              type: string
              maxLength: 25
            description:
              type: string
              maxLength: 75
            landingUrl:
              type: string
              format: uri
            imageUrl:
              type: string
              format: uri
              description: Optional 100x100 image.
        conversation:
          type: object
          additionalProperties: false
          required: [subject, firstMessageId, messages]
          description: |
            POST /v1/ads/create only. Conversation Ad: a choose-your-path message
            tree delivered to the member's LinkedIn inbox. Messages are flat
            nodes wired by local ids; each button either opens a url or leads to
            nextMessageId. Cycles, unknown ids and a missing firstMessageId
            return a 400. LinkedIn does not deliver message ads to EU members.
            Mutually exclusive with the other creative sources.
          properties:
            subject:
              type: string
              description: InMail subject shown in the inbox.
            sender:
              type: string
              description: |
                Person or organization URN. Defaults to the authoring Company
                Page. The sender must be approved for the ad account first
                (Campaign Manager > Manage message ad senders) or LinkedIn
                rejects the create with SINMAIL_SENDER_NOT_APPROVED.
            body:
              type: string
              description: Optional intro body (HTML allowed).
            footer:
              type: string
              description: Terms shown at the bottom of the message.
            headline:
              type: string
              description: Conversation headline. Defaults to the first message's first line.
            firstMessageId: { type: string }
            messages:
              type: array
              minItems: 1
              maxItems: 25
              items:
                type: object
                additionalProperties: false
                required: [id, text]
                properties:
                  id: { type: string }
                  text: { type: string, maxLength: 500 }
                  buttons:
                    type: array
                    minItems: 1
                    maxItems: 5
                    items:
                      type: object
                      additionalProperties: false
                      required: [text]
                      properties:
                        text: { type: string, maxLength: 35 }
                        nextMessageId:
                          type: string
                          description: Continues the conversation at this message. Exactly one of nextMessageId or url.
                        url:
                          type: string
                          format: uri
                          description: Opens this landing page. Exactly one of nextMessageId or url.
        event:
          type: object
          additionalProperties: false
          required: [urn]
          description: |
            POST /v1/ads/create only. Promotes an existing LinkedIn Event; no
            headline needed. Mutually exclusive with the other creative sources.
          properties:
            urn:
              type: string
              pattern: '^urn:li:event:\d+$'
              description: LinkedIn Event URN, urn:li:event:N.
        thoughtLeader:
          type: object
          additionalProperties: false
          required: [postUrn]
          description: |
            POST /v1/ads/create only. Sponsors an existing LinkedIn post
            (a share or ugcPost authored by your organization's Company
            Page) as the creative, keeping its commentary, author and
            engagement. Unlike boostPost, which provisions its own
            CampaignGroup + Campaign around the post, this variant
            attaches the reference under the campaign /v1/ads/create
            builds — same shape as every other format, so the caller can
            pick bidding / targeting / schedule freely. No headline, body,
            imageUrl or organization are needed; the referenced post
            carries its own commentary and author. Mutually exclusive
            with the other creative sources. Posts from personal profiles
            (Thought Leader Ads) are NOT supported (see postUrn).
          properties:
            postUrn:
              type: string
              pattern: '^urn:li:(share|ugcPost):\d+$'
              description: >
                LinkedIn share or ugcPost URN, urn:li:share:N or
                urn:li:ugcPost:N. Get it via "Copy link to post" on the
                target LinkedIn post (the URL contains -share- for a share
                or -ugcPost- for a ugcPost, then the numeric id).
                The post must be authored by an organization (Company
                Page). Member (personal profile) posts, i.e. Thought
                Leader Ads proper, are rejected by LinkedIn's public
                Marketing API regardless of sponsorship approval and of
                post type (a LinkedIn limitation; their Campaign Manager
                creates those through a private API). Referencing a
                member post returns a 422 with a clear error.
      description: >
        LinkedIn-specific options for POST /v1/ads/boost and POST /v1/ads/create:
        campaign bidding and delivery controls, plus the LinkedIn-only creative
        formats on /v1/ads/create. Unknown keys are rejected.

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

    SlackPlatformData:
      type: object
      properties:
        threadTs:
          type: string
          description: 'Parent message ts to post this message as a thread reply (e.g. "1503435956.000247").'
        unfurlLinks:
          type: boolean
          description: 'Expand links in the message into preview cards. Default true.'
        unfurlMedia:
          type: boolean
          description: 'Expand media links into inline previews. Default true.'
        username:
          type: string
          description: 'Override the bot display name for this message only (requires no setup; shown with an APP badge). Does not change the app identity in the sidebar.'
        iconUrl:
          type: string
          description: 'Override the bot avatar image URL for this message only.'
      description: |
        Slack message settings. Posts mrkdwn text (up to 40,000 chars; Slack truncates beyond that) to the channel fixed by the connected account, with up to 10 media files per post uploaded via Slack's file API (the text becomes the caption). The target channel is chosen at connect time — one connected account per channel — so channelId is NOT accepted here (a 400 is returned); connect the desired channel via /v1/connect/slack and target its accountId. Messages over 4,000 characters cannot be edited later (Slack's edit limit is stricter than its post limit).

    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, slack, whatsapp, linkedinads, metaads, pinterestads, tiktokads, xads, googleads, openaiads]
        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 }
        needsReconnection:
          type: boolean
          description: |
            The platform definitively reported the stored OAuth token as dead.
            While true, GET /v1/connect/{platform}/ads returns a
            fresh authUrl (implicit force=true) instead of alreadyConnected,
            so re-running the connect flow recovers the account. Cleared
            automatically when the account is re-authorized.
        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). A declined or pending display name does not by itself block sending; sendability is reported separately via health_status (can_send_message).
            - 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

            For LinkedIn accounts, profileData carries the profile details refreshed on each daily snapshot:
            - profileData.bio: The member's headline for personal accounts, or the organization description for organization accounts. null when the member has not set one.
            - profileData.extraData.vanityName: The member's profile slug, i.e. the /in/{vanityName} segment of profileUrl. Personal accounts only; an organization's own slug is in metadata.organizationInfo.vanityName.
    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
        disabledResourceGroups:
          type: array
          items:
            type: string
            enum: [publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks]
          description: >-
            Resource groups this key can NOT access (opt-out denylist). Absent
            or empty means legacy full access. A key with any group disabled is
            a restricted key (zrk_ prefix) and can never manage API keys,
            invites, or member identity. Each operation's group is published as
            x-resource-group. With 'messages' disabled, the key cannot read or
            send private messages through any API surface, and it cannot create
            or edit a webhook subscription broader than itself: it cannot
            subscribe to, test-fire, redeliver, or read delivery logs for
            message events. Subscriptions created earlier, from the dashboard,
            or with a full-access key keep delivering whatever their own
            `disabledResourceGroups` allows, so restricting an existing
            integration end to end means restricting the subscription too.
            OAuth connector tokens (AI assistants and MCP clients) resolve
            against the same registry, but their groups are not settable yet:
            treat an authorized connector as full access.
    ConnectedApp:
      type: object
      description: |
        An OAuth client (AI assistant / MCP connector) authorized by the user and still
        holding at least one live token.
      properties:
        clientId: { type: string }
        clientName:
          type: string
          description: 'Name the client declared at registration. Registration is open, so this is self-declared and not verified.'
        redirectHost:
          type: [string, "null"]
          description: 'Host of the client''s registered redirect URI (non-http schemes are shown as scheme//host). The destination an impostor cannot fake.'
        scopes:
          type: array
          items: { type: string }
          description: Scopes granted on the most recent token.
        authorizedAt: { type: [string, "null"], format: date-time }
        lastUsedAt:
          type: [string, "null"]
          format: date-time
          description: Last time any of the client's live tokens authenticated a request.
        tokenCount:
          type: integer
          description: 'Live tokens held by the client (an active session is typically one access plus one refresh token).'
    BillingSnapshot:
      type: object
      description: |
        Account billing state — plan, cycle, balance, spend caps, and payment /
        access status. Returned by `GET /v1/billing`.
      properties:
        billingSystem:
          type: string
          enum: [metronome, stripe]
        plan:
          type: object
          properties:
            name: { type: string }
            isUsageBased: { type: boolean }
        period:
          type: object
          description: Current billing cycle. `start`/`end` are resolved for usage-based accounts only.
          properties:
            start: { type: [string, "null"], format: date-time }
            end: { type: [string, "null"], format: date-time }
            anchorDay: { type: integer, description: Day-of-month the cycle resets. }
        balance:
          type: [object, "null"]
          description: Accrued spend + remaining credits this cycle. `null` for fixed-subscription (Stripe) plans.
          properties:
            accruedThisPeriodCents: { type: integer }
            creditsRemainingCents: { type: integer }
        caps:
          type: object
          properties:
            xSpendUsedCents: { type: integer }
            xSpendLimitCents: { type: [integer, "null"], description: Monthly X-API spend cap; null = unlimited. }
        status:
          type: object
          properties:
            hasAccess: { type: boolean }
            suspended: { type: boolean }
            suspendedAt: { type: [string, "null"], format: date-time }
            suspensionReason: { type: [string, "null"] }
            openInvoiceUrl: { type: [string, "null"], description: Hosted invoice URL for dunning (Stripe). }
            declineReason: { type: [string, "null"] }
            autoUpgradeEnabled: { type: boolean }
        legacy:
          type: object
          description: Deprecated plan entitlements (Stripe only); absent for usage-based accounts.
          properties:
            limits:
              type: object
              properties:
                uploads: { type: integer }
                profiles: { type: integer }
    UsageMetering:
      type: object
      description: |
        Billed spend by product family over a window, from Metronome's invoice
        breakdown (the CHARGE view). Returned by `GET /v1/usage`.
      properties:
        supported:
          type: boolean
          description: False for legacy Stripe accounts (no Metronome invoice to split); `days` and `totals` are then empty/zero.
        granularity:
          type: string
          enum: [day, month, total]
        days:
          type: array
          description: One row per bucket. Empty when `granularity=total`. `date` is a UTC date (month buckets use the 1st).
          items:
            type: object
            properties:
              date: { type: string, format: date }
              accounts: { type: number }
              numbers: { type: number }
              calls: { type: number }
              sms: { type: number }
              dlc: { type: number, description: 10DLC registration (brand + campaign) fees. }
              xApi: { type: number }
              credits: { type: number, description: Applied credits/discounts (negative). }
              other: { type: number }
        totals:
          type: object
          description: Sum of each product over the whole window (USD), plus `total`. Unaffected by `granularity`.
          properties:
            accounts: { type: number }
            numbers: { type: number }
            calls: { type: number }
            sms: { type: number }
            dlc: { type: number }
            xApi: { type: number }
            credits: { type: number }
            other: { type: number }
            total: { type: number }
        lineItems:
          type: array
          description: Per-invoice-line-item rows (largest spend first) for a detailed breakdown.
          items:
            type: object
            properties:
              name: { type: string }
              product:
                type: string
                enum: [accounts, numbers, calls, sms, dlc, xApi, credits, other]
              totalUsd: { type: number }
              quantity: { type: number }
        peaks:
          type: object
          description: Peak counts over the window (Metronome COUNT metrics + live active-number count).
          properties:
            accounts: { type: integer }
            numbers: { type: integer }
        callUsage:
          type: object
          description: Billable call volumes over the window.
          properties:
            whatsapp:
              type: object
              properties:
                count: { type: integer }
                minutes: { type: integer }
            pstn:
              type: object
              properties:
                count: { type: integer }
                minutes: { type: integer }
        period:
          type: object
          properties:
            start: { type: string, format: date-time }
            end: { type: string, format: date-time }
            source:
              type: string
              enum: [cycle, window]
              description: '`cycle` = a real billing period resolved; `window` = trailing/custom window (or cycle fallback).'
        tax:
          type: [object, "null"]
          description: |
            Estimated tax on the window's net `totals.total`, computed with
            Stripe Tax against the billing address (the same engine the real
            invoice uses; invoices apply exclusive tax, so the card is charged
            total + tax). Null when the account has no billing address on
            file, the total is zero or negative, or the estimate failed.
          properties:
            taxUsd: { type: number, description: "Estimated tax in USD, added on top of `totals.total`." }
            ratePercent: { type: [number, "null"], description: "Combined rate percentage, e.g. 21." }
            jurisdictionLabel: { type: [string, "null"], description: 'Human jurisdiction label, e.g. "ES VAT" or "WA sales tax".' }
            reverseCharge: { type: boolean, description: True for EU/UK B2B reverse charge (0 tax added by design). }
    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 }
        follows: { type: integer, example: 0, description: 'Instagram feed posts and stories only: organic accounts that started following from this post. 0 for reels and other platforms.' }
        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.' }
        videoDurationSeconds: { type: [integer, "null"], example: 30, description: 'Video length in seconds. Currently Instagram Reels only; combine with igReelsAvgWatchTime (ms) to estimate retention. Null when unknown (other platforms, non-video media, or when Instagram does not expose the media URL, e.g. reels with copyrighted audio).' }
        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) }
              altText:
                type: string
                description: Accessibility alt text set on the media, when present.
    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) }
                    altText:
                      type: string
                      description: Accessibility alt text set on the media, when present.
        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'
        warnings:
          type: array
          items:
            type: string
    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'
        total:
          type: integer
          description: 'Total matching profiles across all pages. Present only when limit or skip was passed.'
        skip:
          type: integer
          description: 'Offset applied. Present only when limit or skip was passed.'
        limit:
          type: integer
          description: Echo of the limit query param. Present only when it was passed.
    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
          description: "Unique people reached in the requested date range. Meta (facebook/instagram) and TikTok: the platform's own de-duplicated reach for the exact range, fetched live and cached up to ~1 hour (may lag recent delivery; on a transient platform error the value temporarily falls back to a sum of per-day reach, which overcounts people reached on multiple days or by multiple child ads). Because it is de-duplicated, reach is NOT additive on these platforms: neither daily values nor child nodes sum to the range total. Google, LinkedIn, X, Pinterest and OpenAI report 0 (reach not synced). Frequency (impressions / reach) is only meaningful for Meta and TikTok."
        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: number
          description: "Count of conversion events over the requested date range. FRACTIONAL: attribution splits one conversion across touchpoints and Google additionally reports modeled conversions, so values like 0.347 are normal. Meta: events matching the campaign's promoted_object.custom_event_type (PURCHASE, LEAD, etc.). Google: the account's tracked conversions. X and LinkedIn: their reported website/lead conversions (added 2026-07). 0 for non-conversion campaigns or when no events have fired."
        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: "Per-action-type counts summed over the date range, keyed by the platform's action-type names. Meta: raw Insights action_type keys (link_click, offsite_conversion.fb_pixel_purchase, onsite_conversion.lead_grouped, ...) — both engagement and conversion events. X: conversion types (purchase, sign_up, site_visit, download, custom). LinkedIn: conversion types (post_click, post_view, lead_gen). Google returns {} (its per-action names aren't synced per ad). Empty object when no actions are reported. NOTE: keys differ by platform, so branch on the ad's platform when interpreting them."
          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."
        costPerThruplay:
          type: number
          description: "Derived `spend / videoThruplayWatchedActions`, in ad-account native currency. Rounded to 4 decimals rather than the usual 2 because a ThruPlay routinely costs well under a cent. 0 when the ad has no ThruPlays."
        funnel:
          $ref: '#/components/schemas/AdFunnelCounts'
        engagementBreakdown:
          $ref: '#/components/schemas/AdEngagementCounts'
        lastSyncedAt: { type: string, format: date-time, description: "Present on individual ads only, not on campaign aggregations" }
    AdFunnelCounts:
      type: object
      description: |
        Named conversion-funnel steps, resolved from the same data as `actions`
        so you never have to parse action-type strings yourself.

        Meta reports one event under several action types at once
        (`offsite_conversion.fb_pixel_purchase`, `omni_purchase`, `purchase`, …).
        Each field below takes the FIRST family member present rather than
        summing them, which is what makes these counts safe to add up — summing
        the raw `actions` keys yourself double or triple counts. The same
        priority order backs `conversions`, so a purchase-optimised campaign
        reports the identical number in `conversions` and `funnel.purchases`.

        Every field is 0 when that step never fired. Populated for Meta ads;
        other platforms report a different action taxonomy and generally leave
        these at 0 (read `actions` for those). At ad-set and campaign level each
        step is summed from its per-ad values.
      properties:
        landingPageViews: { type: integer, description: "Landing page views — the visitor actually loaded the destination, unlike a link click. Meta `landing_page_view`." }
        contentViews: { type: integer, description: "Content views (Meta `ViewContent` pixel event)." }
        searches: { type: integer, description: "On-site searches (Meta `Search` pixel event)." }
        wishlistAdds: { type: integer, description: "Adds to wishlist (Meta `AddToWishlist` pixel event)." }
        cartAdds: { type: integer, description: "Adds to cart (Meta `AddToCart` pixel event)." }
        checkoutsInitiated: { type: integer, description: "Checkouts started (Meta `InitiateCheckout` pixel event)." }
        paymentInfoAdds: { type: integer, description: "Payment details added at checkout (Meta `AddPaymentInfo` pixel event)." }
        purchases: { type: integer, description: "Purchases (Meta `Purchase` pixel event). Pair with `purchaseValue` for revenue." }
        leads: { type: integer, description: "Leads, from either the website pixel or an instant form — whichever the ad uses." }
        registrationsCompleted: { type: integer, description: "Completed registrations (Meta `CompleteRegistration` pixel event)." }
        appInstalls: { type: integer, description: "Mobile app installs attributed to the ad." }
        messagingConversationsStarted: { type: integer, description: "Messaging conversations started within 7 days — the headline metric for click-to-WhatsApp and click-to-Messenger ads." }
        messagingFirstReplies: { type: integer, description: "Messaging threads where the person sent a first reply." }
    AdEngagementCounts:
      type: object
      description: |
        The single `engagement` total split into the interactions behind it.

        Note that `engagement` is not the sum of these: Meta's own
        `post_engagement` and `page_engagement` totals already contain the
        individual interactions, and all of them are counted into `engagement`.
        Use these fields when you need a specific interaction, and `engagement`
        only as the coarse total it has always been.

        Meta-only; other platforms leave these at 0.
      properties:
        postEngagement: { type: integer, description: "Meta's own post-engagement total (`post_engagement`)." }
        pageEngagement: { type: integer, description: "Meta's own page-engagement total (`page_engagement`)." }
        reactions: { type: integer, description: "Reactions on the ad's post (`post_reaction`)." }
        comments: { type: integer, description: "Comments on the ad's post." }
        shares: { type: integer, description: "Shares of the ad's post. Meta reports these under the action type literally named `post`." }
        saves: { type: integer, description: "Saves of the ad's post (`onsite_conversion.post_save`)." }
        pageLikes: { type: integer, description: "New Page likes attributed to the ad (`like`)." }
        videoViews: { type: integer, description: "3-second video views (`video_view`). For completion-based counts use `videoThruplayWatchedActions`." }
        linkClicks: { type: integer, description: "Attributed link clicks (`link_click`). This is the attribution-window count, which differs from the in-session `inline_link_clicks` reported by `GET /v1/ads/{adId}/analytics`." }
    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. `reach` is the exception: on Meta and
        TikTok the aggregated total is de-duplicated across the range, so daily
        reach does not sum to it. 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]
    AdReviewStatus:
      type: string
      enum: [in_review, approved, rejected, with_issues]
      description: "Platform-side review state, independent of the delivery `status` and the `configuredStatus` on/off toggle. `in_review` means the platform is still reviewing. Absent when the platform reports no review signal (e.g. a paused ad whose review state is masked behind the pause)."
    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
    ValueRuleCriterion:
      type: object
      additionalProperties: false
      required: [criteriaType, operator, criteriaValues, criteriaValueTypes]
      description: |
        One matching condition inside a value rule. `criteriaValues` and `criteriaValueTypes`
        are POSITIONALLY paired: same length, same order.
      properties:
        id:
          type: string
          description: |
            Platform criterion id. Echo it on `PUT` to KEEP this criterion, omit it to CREATE a
            new one. A criterion left out of the array entirely is DELETED.
        criteriaType:
          type: string
          enum: [AGE, GENDER, OS_TYPE, DEVICE_PLATFORM, LOCATION, PLACEMENT, OMNI_CHANNEL, AUDIENCE_LABEL]
          description: |
            The dimension being matched. `OMNI_CHANNEL` (conversion location: APP, INSTANT_FORM,
            PHONE_CALL, WEBSITE) is accepted even though Meta's own enum table omits it.
        operator:
          type: string
          enum: [CONTAINS]
          description: "Required on every criterion. `CONTAINS` is currently the only value Meta supports."
        criteriaValues:
          type: array
          minItems: 1
          items: { type: string }
          description: |
            The values to match. `AGE` takes ranges such as `18-24`, `18+` or a custom `18-26`;
            a range whose upper bound is 65 is NOT allowed (use `18+` instead of `18-65`).
            `LOCATION` takes Targeting-Search keys: a two-letter country code for
            `LOCATION_COUNTRY`, a numeric key for region / city / comScore market.
            `AUDIENCE_LABEL` takes labels such as `HIGH_VALUE`, which are applied to a Custom
            Audience in Ads Manager: there is no API to provision them, so they are passed
            through unvalidated.
        criteriaValueTypes:
          type: array
          minItems: 1
          items: { type: string }
          description: |
            One entry per `criteriaValues` entry, in the same order. The literal `"NONE"` for
            every criteriaType except `LOCATION`, which uses `LOCATION_COUNTRY`,
            `LOCATION_REGION`, `LOCATION_CITY` or `LOCATION_COMSCORE_MARKET` and MAY mix them
            within one criterion. `LOCATION_DMA` was replaced by `LOCATION_COMSCORE_MARKET` on
            2026-06-22 and is rejected by this API.
    ValueRule:
      type: object
      additionalProperties: false
      required: [name, adjustSign, adjustValue, criteria]
      description: |
        One bid-adjustment rule. Rules are evaluated in ARRAY ORDER and only the first matching
        rule adjusts the bid for an overlapping audience, so the order is semantic.
      properties:
        id:
          type: string
          description: |
            Platform rule id. Echo it on `PUT` to KEEP this rule, omit it to CREATE a new one.
            A rule left out of the array entirely is DELETED.
        name: { type: string, minLength: 1, maxLength: 255 }
        adjustSign:
          type: string
          enum: [INCREASE, DECREASE]
          description: "Direction of the adjustment. There is no signed value field."
        adjustValue:
          type: integer
          minimum: 1
          maximum: 1000
          description: |
            Unsigned percentage magnitude. `INCREASE` accepts 1-1000, `DECREASE` accepts 1-90.
            0 is out of range on both.
        status:
          type: string
          description: |
            Meta returns `ACTIVE` here but documents no enum for the field. Treat it as a
            passthrough: echo whatever the `GET` returned, and do not synthesize values.
        criteria:
          type: array
          minItems: 1
          maxItems: 4
          items: { $ref: '#/components/schemas/ValueRuleCriterion' }
          description: "All criteria on a rule must match for the rule to fire."
    ValueRuleSet:
      type: object
      additionalProperties: false
      required: [id, name, rules]
      description: |
        A named set of bid-adjustment rules on an ad account. Attach it to an ad set with
        `valueRuleSetId`. Limits: 6 sets per ad account, 10 rules per set, 4 criteria per rule.
      properties:
        id: { type: string, description: "Platform value rule set id." }
        name: { type: string }
        rules:
          type: array
          items: { $ref: '#/components/schemas/ValueRule' }
          description: "Evaluated in order; the first matching rule wins."
    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` + `distanceUnit` 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 distanceUnit. Meta enforces a minimum city radius (~17 km / 10 mi); smaller values resolve to a 0-size audience and the ad fails at launch. For a tighter catchment use customLocations (lat/lng), which allows a smaller radius." }
              distanceUnit: { 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` + `distanceUnit` 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 distanceUnit." }
                  distanceUnit: { 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. Applied on Meta, TikTok and Pinterest. Ignored on Google, LinkedIn and X." }
        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 restricting the audience by language. On Meta, ISO 639-1 codes (e.g. ['en']); a bare code targets all regional variants (\"en\" = all English), or use a region-qualified code (\"en_GB\", \"pt_BR\") for a specific one. Unknown codes are rejected." }
        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, openai] }
        status: { allOf: [{ $ref: '#/components/schemas/AdStatus' }], description: "Delivery status. Derived from the platform `effective_status`, so it inherits ancestor pauses (an ACTIVE ad under a PAUSED campaign reads `paused`). For the ad's own on/off toggle use `configuredStatus`; for the review state use `reviewStatus`." }
        configuredStatus:
          type: [string, "null"]
          description: "The ad's own on/off toggle as configured on the platform (Meta `configured_status`: ACTIVE / PAUSED), unaffected by ancestor (ad set / campaign) pauses. Distinct from `status`, which is the ancestor-cascaded delivery status. Only present for Meta ads synced after this field was added."
          example: ACTIVE
        reviewStatus:
          allOf: [{ $ref: '#/components/schemas/AdReviewStatus' }]
          description: "Platform review state of this ad, independent of delivery `status` / `configuredStatus`. Absent when the platform reports no review signal."
        adType: { type: string, enum: [boost, standalone] }
        creativeType:
          type: [string, "null"]
          enum: [carousel, video, document, image, null]
          description: "Creative format, classified from the media the creative carries. `null` when the creative carries no media to classify — an unsynced creative and a genuine text-only ad are indistinguishable, so neither is guessed at. Returned by `GET /v1/ads`, `GET /v1/ads/{adId}` and the ad nodes of `GET /v1/ads/tree`."
          example: video
        goal: { type: string, enum: [engagement, traffic, awareness, video_views, lead_generation, lead_conversion, conversions, app_promotion, catalog_sales, job_applicants], 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: |
            What the delivery system optimizes for, at ad-set level. The value space depends on `platform`:

            - Meta: ad set `optimization_goal` (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION, LINK_CLICKS).
            - LinkedIn: the campaign's EFFECTIVE `optimizationTargetType`, refreshed from LinkedIn on every
              sync rather than echoing what was passed on create. `NONE` means manual bidding, and it is a
              real value, not missing data. Auto-bid values are MAX_IMPRESSION / MAX_CLICK / MAX_CONVERSION /
              MAX_VIDEO_VIEW / MAX_LEAD / MAX_REACH; target-cost values are TARGET_COST_PER_CLICK /
              TARGET_COST_PER_IMPRESSION / TARGET_COST_PER_VIDEO_VIEW; cost-cap values are the
              CAP_COST_AND_MAXIMIZE_* family.
          example: OFFSITE_CONVERSIONS
        costType:
          type: [string, "null"]
          description: |
            LinkedIn only. The campaign's EFFECTIVE cost model (billing event) as applied by LinkedIn,
            refreshed on every sync rather than echoing what was passed on create. One of `CPM` (cost per
            thousand impressions), `CPC` (cost per click) or `CPV` (cost per video view). On LinkedIn this is
            the axis that pairs with `bidAmount`; there is no `bidStrategy`. For campaign type
            SPONSORED_INMAILS, `CPM` bills as cost-per-send x 1000. `null` for non-LinkedIn ads.
          example: CPC
        servingStatuses:
          type: array
          items: { type: string }
          description: |
            LinkedIn only. Why the parent campaign is (or is not) delivering, verbatim from LinkedIn.
            A campaign can report `status: ACTIVE` and still serve nothing; this array is what says so.

            - `[]` means no serving data: a non-LinkedIn ad, or a LinkedIn ad not yet re-synced.
            - `["RUNNABLE"]` means the campaign is eligible to serve.
            - Anything else is a hold. Known values include ACCOUNT_SERVING_HOLD, ACCOUNT_TOTAL_BUDGET_HOLD,
              ACCOUNT_END_DATE_HOLD, CAMPAIGN_START_DATE_HOLD, CAMPAIGN_END_DATE_HOLD,
              CAMPAIGN_TOTAL_BUDGET_HOLD, CAMPAIGN_AUDIENCE_COUNT_HOLD, CAMPAIGN_GROUP_START_DATE_HOLD,
              CAMPAIGN_GROUP_END_DATE_HOLD, CAMPAIGN_GROUP_TOTAL_BUDGET_HOLD, CAMPAIGN_GROUP_STATUS_HOLD and
              STOPPED. The list is open on purpose, so treat unrecognized values as holds rather than errors.

            The end-date and total-budget holds are terminal and surface as `status: completed`; the rest
            surface as `status: paused`. Note that a hold is not the only cause of zero delivery: with
            manual, target-cost or cost-cap bidding, a `bidAmount` of 0 stops delivery while
            `servingStatuses` still reads `["RUNNABLE"]`. Check `costType` / `bidAmount` /
            `optimizationGoal` as well.
          example: ["ACCOUNT_TOTAL_BUDGET_HOLD"]
        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 amount in WHOLE currency units of the ad account (USD: 5 = $5.00; JPY: 100 = ¥100).

            - Meta source: `bid_amount` on the ad set (smallest-denomination int, decoded here). Populated
              when bidStrategy is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`; `null` for auto-bid
              (`LOWEST_COST_WITHOUT_CAP`).
            - 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.
            - LinkedIn source: the campaign's EFFECTIVE `unitCost`, refreshed on every sync rather than
              echoing what was passed on create. Its meaning depends on the bidding mode implied by
              `optimizationGoal`: bid amount (manual), target cost, or cost cap. It pairs with `costType`,
              NOT with `bidStrategy`, which LinkedIn does not have. A value of `0` is a real, delivery-
              stopping configuration and not "unset", so do not gate this field on `bidStrategy` for
              LinkedIn ads.

            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, "null"], 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." }
            pageId: { type: [string, "null"], description: "Facebook Page backing the creative (Meta only). What the `pageId` filter on /v1/ads, /v1/ads/campaigns and /v1/ads/tree matches against. Absent for non-Meta ads and rare Meta creatives with no page signal." }
            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).
            isServing: { type: [boolean, "null"], description: "LinkedIn only. Whether LinkedIn is currently serving this specific creative. Complements the ad-level `servingStatuses`, which describes the parent campaign." }
            servingHoldReasons:
              type: array
              items: { type: string }
              description: |
                LinkedIn only. Why this specific creative is not being served. Empty when it is serving.
                A superset of the ad-level `servingStatuses`: it repeats the inherited campaign, campaign
                group and account holds AND adds creative-only causes such as UNDER_REVIEW, REJECTED,
                PROCESSING, PROCESSING_FAILED, FORM_HOLD (lead-gen-form creatives),
                REFERRED_CONTENT_QUALITY_HOLD, JOB_POSTING_ON_HOLD and JOB_POSTING_INVALID (job ads).
                Some values are format-specific and will never appear on other ad formats. The list is
                open, so treat unrecognized values as holds rather than errors.
              example: ["UNDER_REVIEW"]
            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: "What the delivery system optimizes for. Meta ad set optimization goal (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION), or on LinkedIn the campaign's effective optimizationTargetType (NONE means manual bidding). See the `optimizationGoal` field on `Ad` for the full value spaces." }
        bidStrategy:
          anyOf:
            - $ref: '#/components/schemas/BidStrategy'
            - type: "null"
          description: "Bid strategy for this ad set (overrides campaign level when set). Meta and TikTok only; LinkedIn uses `costType` instead."
        bidAmount: { type: [number, "null"], description: "Bid amount in whole currency units. On Meta/TikTok populated when bidStrategy is LOWEST_COST_WITH_BID_CAP or COST_CAP; on LinkedIn it is the campaign's effective unitCost and pairs with `costType`, where 0 is a real, delivery-stopping value." }
        roasAverageFloor: { type: [number, "null"], description: "Minimum ROAS as a decimal multiplier (2.0 = 2.0x). Populated when bidStrategy is LOWEST_COST_WITH_MIN_ROAS." }
        costType: { type: [string, "null"], description: "LinkedIn only. Effective cost model (billing event) of the LinkedIn campaign backing this ad set: CPM, CPC or CPV. Null for non-LinkedIn ad sets." }
        servingStatuses:
          type: array
          items: { type: string }
          description: "LinkedIn only. Why the LinkedIn campaign backing this ad set is (or is not) delivering. A LinkedIn Campaign maps to this ad-set node, so this is the level where LinkedIn's holds actually apply. Empty means no serving data, [\"RUNNABLE\"] means eligible to serve, anything else is a hold. See the `servingStatuses` field on `Ad` for the known values."
          example: ["RUNNABLE"]
        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: `_id`, `name`, `platform`, `status`, `configuredStatus`, `reviewStatus`, `budget`, `metrics`, `creative`, `goal` and the `platform*` ids are always included; `targeting` and `schedule` may be absent. `configuredStatus` (the ad's own on/off toggle) and `reviewStatus` (the platform's review verdict) are part of this contract, not incidental: a rejected ad is only distinguishable from a healthy one through `reviewStatus`. 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, openai] }
        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:
          anyOf:
            - $ref: '#/components/schemas/AdReviewStatus'
            - type: "null"
          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: "Optimization goal shared across ad sets, or comma-separated values when ad sets differ. Meta: e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION. LinkedIn: the campaign optimizationTargetType (e.g. MAX_CLICK, MAX_IMPRESSION, NONE); `NONE` with a manual costType is a campaign LinkedIn will not deliver."
        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 for the campaign, bubbled up from the top-spending ad set (whole currency units). Meta: populated when the ad-set bidStrategy is LOWEST_COST_WITH_BID_CAP or COST_CAP. LinkedIn: the campaign unitCost, which has no bidStrategy gate and where 0 is a real, delivery-stopping value rather than unset." }
        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, except `reach`: on Meta the range total is de-duplicated, so daily reach does not sum to it."
    AdCampaign:
      type: object
      properties:
        platformCampaignId: { type: string }
        platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter, openai] }
        campaignName: { type: string }
        status: { allOf: [{ $ref: '#/components/schemas/AdStatus' }], description: "Delivery status derived from child ad statuses. Distinct from `reviewStatus`." }
        reviewStatus:
          anyOf:
            - $ref: '#/components/schemas/AdReviewStatus'
            - type: "null"
          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: "Optimization goal shared across ad sets, or comma-separated values when ad sets differ. Meta: e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION. LinkedIn: the campaign optimizationTargetType (e.g. MAX_CLICK, MAX_IMPRESSION, NONE); `NONE` with a manual costType is a campaign LinkedIn will not deliver."
        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 from the top-spending ad set (whole currency units). Meta: populated when bidStrategy is LOWEST_COST_WITH_BID_CAP or COST_CAP. LinkedIn: the campaign unitCost, ungated, where 0 is a real delivery-stopping value." }
        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 and
            OpenAI Ads).

            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.
            - OpenAI Ads: a fixed subset of standard names (Purchase, Lead, AddToCart, ViewContent, InitiateCheckout, CompleteRegistration, Subscribe, StartTrial, Schedule) maps 1:1 onto OpenAI's own event-type enum; anything else is sent as a custom event with the name preserved.
          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." }
            leadId:
              type: string
              description: |
                Meta lead ID from a Lead Ad submission, as a string. Required
                for Conversion Leads CRM events: send it with
                `actionSource: 'crm'` and
                `platformData: { event_source: 'crm', lead_event_source: '<CRM name>' }`.
                Forwarded unhashed to Meta's `user_data.lead_id`. 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 also requires an event source internally; omitting this field sends OTHER to Google. Send an explicit value for accurate origin reporting.'
        platformData:
          type: object
          additionalProperties: true
          description: |
            Escape hatch for platform-specific fields we haven't normalized.
            On Meta, keys are shallow-merged into `custom_data` only: fields
            Zernio already builds (`value`, `currency`, `contents`,
            `num_items`) always win on collision, and `user_data` (hashed
            match keys) is never touched. Use first-class fields (e.g.
            `user.leadId`) for anything that must reach `user_data`.

    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 whenever a post enters the scheduled state: created with a schedule, added to a queue, a draft promoted to scheduled or queued, a failed or partial post retried, or a recycled clone created. Not fired when an already-scheduled post is edited or rescheduled.'
      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). The new clone also fires a post.scheduled event.'
      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.platform.deleted:
    post:
      operationId: onPostPlatformDeleted
      summary: Post platform deleted event
      description: |
        Fired when Zernio's background sync detects that a platform target
        published through Zernio was later deleted on the platform (e.g.
        the user deleted the Instagram post natively). Detection is
        poll-driven (~hourly), not real-time, and fires once per platform
        target. `platform.deletedAt` carries the detection time. Detection
        is listing-based: a false positive self-heals in Zernio's data when
        the post reappears, but the event is not retracted. Coverage is
        bounded to the posts the platform listing returns.
      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, or from the WhatsApp Business app on Coexistence numbers.
      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, Telegram, Slack, Instagram and Facebook Messenger.
        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 and Meta
        removals the platform does not report which emoji was removed, so
        `reaction.emoji` may be an empty string.
        Instagram and Facebook accounts connected before reactions shipped only
        emit this event after their webhook subscription is refreshed; reconnect
        the account if reactions never arrive. 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.automatic_event:
    post:
      operationId: onWhatsAppAutomaticEvent
      summary: WhatsApp automatic event detected
      description: |
        Fired when Meta's automatic event identification (opt-in during
        Embedded Signup; not available for EU/UK/JP businesses) detects a
        lead or purchase in a Click-to-WhatsApp conversation. Branch on
        `eventName` (`LeadSubmitted` | `Purchase`). Carries the `ctwa_clid`
        even on coexistence numbers where the inbound referral omits it (this
        webhook is the only surface that delivers it there); the clid is also
        written back onto the conversation, so POST /v1/whatsapp/conversions
        becomes usable for the thread.
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                id: { type: string }
                event: { type: string, enum: [whatsapp.automatic_event] }
                timestamp: { type: string, format: date-time }
                accountId:
                  type: string
                  description: SocialAccount id of the WhatsApp number whose conversation was flagged.
                conversationId:
                  type: string
                  description: Zernio conversation id, when the thread could be resolved.
                platformMessageId:
                  type: string
                  description: The wamid of the message Meta's analysis flagged.
                eventName:
                  type: string
                  description: "Meta-detected event: `LeadSubmitted` | `Purchase`."
                ctwaClid:
                  type: string
                  description: Meta's CTWA click id, the Conversions API match key.
                customData:
                  type: object
                  description: Purchase events may carry the detected amount.
                  properties:
                    currency: { type: string }
                    value: { type: number }
                detectedAt: { type: string, format: date-time }
      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, verification.approved, verification.failed] }
                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
  verification.approved:
    post:
      operationId: onVerificationApproved
      summary: Verification approved event
      description: |
        Fired when a managed-OTP verification is approved (the user submitted
        the correct code to POST /v1/verify/verifications/{verificationId}/check).
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                id: { type: string }
                event: { type: string, enum: [verification.approved] }
                timestamp: { type: string, format: date-time }
                verification:
                  type: object
                  properties:
                    verificationId: { type: string }
                    channel: { type: string, enum: [sms] }
                    to: { type: string }
      responses:
        '200':
          description: Webhook received successfully
  verification.failed:
    post:
      operationId: onVerificationFailed
      summary: Verification failed event
      description: |
        Fired when a managed-OTP verification is exhausted (the maximum number
        of wrong code attempts was reached).
      tags: [Webhook Events]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                id: { type: string }
                event: { type: string, enum: [verification.failed] }
                timestamp: { type: string, format: date-time }
                verification:
                  type: object
                  properties:
                    verificationId: { type: string }
                    channel: { type: string, enum: [sms] }
                    to: { type: string }
                reason: { type: string, enum: [max_attempts_reached] }
      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:
      x-resource-group: "publishing"
      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:
      x-resource-group: "publishing"
      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. Account lookups are limit-only: a twitter accountId is resolved, scoped to the caller, only to pick the 280 vs 25000 character limit. Missing, foreign, or invalid ids fall back to 280 and never error.

        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, slack]
                      accountId: { type: string, description: 'Account to validate against. For twitter, resolves X Premium status to apply the 25000 character limit instead of 280.' }
                      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:
      x-resource-group: "publishing"
      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:
      x-resource-group: "publishing"
      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:
      x-resource-group: "analytics"
      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, follows], 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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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). YouTube finalizes analytics
        with a ~3-day delay; by default only finalized days are returned, and an explicit endDate can reach
        into the delay window (see the endDate parameter). 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, the newest fully finalized day
            (YouTube finalizes analytics with a ~3-day delay). An explicit endDate is honored
            up to today: days inside the delay window are provisional and may still be revised
            by YouTube (see provisionalSince in the response), and days YouTube has not
            processed yet are omitted from dailyViews.
      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:
      x-resource-group: "analytics"
      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, the newest fully finalized day
            (YouTube finalizes analytics with a ~3-day delay). An explicit endDate is honored
            up to today: days inside the delay window are provisional and may still be revised
            by YouTube (see provisionalSince in the response).
      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:
      x-resource-group: "analytics"
      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

            Monetization (opt-in, not in the defaults):
              - content_monetization_earnings
              - monetization_approximate_earnings

            Each monetization metric is fetched with its own separate Graph call, so requesting both
            adds two calls. Values are approximate and Meta restates them after the fact.

            content_monetization_earnings returns an object per day and always carries unit
            "micro_amount" plus an ISO 4217 "currency". monetization_approximate_earnings returns a bare
            number per day, so its unit is always "unspecified" and its "currency" is always null. The two
            are on different scales and are not comparable to each other. Both keep their daily "values"
            on every metricType and are never rescaled by Zernio.

            Earnings here are Page-level daily buckets and "total" is their sum. Meta does not
            document whether a bucket carries that day's earnings or a running total, and every
            Page measured so far earned exactly 0, so reconcile "total" against the Page's own Meta
            export before relying on it; the daily "values" are always returned for that purpose.
            Per-post lifetime earnings are served by GET /v1/analytics/facebook/post-earnings.

            A Page that is not enrolled in monetization, or that earned nothing, returns normal daily
            buckets of 0 in "metrics": Meta does not distinguish the two, so a 0 total here does NOT mean
            the Page is enrolled. "unavailableMetrics" covers the narrower case where Meta returned no
            bucket for the metric at all ("no_data") or rejected the request outright, and the metric is
            then omitted from "metrics" rather than reported as 0.
        - 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. Monetization earnings are approximate and Meta restates them after the fact."
                monetization:
                  summary: 'Both monetization metrics, each at its own scale'
                  value:
                    success: true
                    accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
                    platform: "facebook"
                    dateRange: { since: "2026-06-01", until: "2026-06-30" }
                    metricType: "total_value"
                    metrics:
                      page_media_view:
                        total: 125000
                      content_monetization_earnings:
                        total: 1234000000
                        unit: "micro_amount"
                        currency: "USD"
                        values:
                          - { date: "2026-06-01", value: 634000000 }
                          - { date: "2026-06-02", value: 600000000 }
                      monetization_approximate_earnings:
                        total: 1234
                        unit: "unspecified"
                        currency: null
                        values:
                          - { date: "2026-06-01", value: 700 }
                          - { date: "2026-06-02", value: 534 }
                    dataDelay: "Meta page insights may be delayed up to 24 hours. Metrics reflect the current (post-November 2025) Graph API names. Monetization earnings are approximate and Meta restates them after the fact."
                monetizationUnavailable:
                  summary: 'A monetization metric Meta returned no bucket for'
                  value:
                    success: true
                    accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
                    platform: "facebook"
                    dateRange: { since: "2026-06-01", until: "2026-06-30" }
                    metricType: "total_value"
                    metrics:
                      page_media_view:
                        total: 125000
                    unavailableMetrics:
                      - metric: "content_monetization_earnings"
                        reason: "no_data"
                        message: "Meta returned no data for this monetization metric."
                    dataDelay: "Meta page insights may be delayed up to 24 hours. Metrics reflect the current (post-November 2025) Graph API names. Monetization earnings are approximate and Meta restates them after the fact."
        '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/facebook/post-earnings:
    get:
      x-resource-group: "analytics"
      operationId: getFacebookPostEarnings
      tags: [Analytics]
      summary: Get Facebook post monetization earnings
      description: |
        Returns lifetime monetization earnings for ONE Facebook post, read live from Meta on every
        request. Requires the Analytics add-on.

        Earnings are CUMULATIVE since the post was published, not earnings within a date range, so
        this endpoint takes no since/until and the totals must not be summed across dates or across
        posts. Page-level daily earnings live on /v1/analytics/facebook/page-insights.

        A post on a Page that is not enrolled in monetization, or that earned nothing, returns
        "total": 0 rather than an error: Meta does not distinguish the two. A metric Meta returned no
        bucket for at all is reported in "unavailableMetrics" and omitted from "metrics", never as a 0.

        Amounts are the platform's raw numbers in the stated "unit" and are never rescaled by Zernio.
        Breakdown dimensions are not exposed and a "breakdown" param is rejected with 400. So are
        "since", "until", "period", and "metricType": scoping this endpoint to a window is not
        possible, and silently returning the lifetime total for one would let a caller sum a year of
        weekly requests into a figure ~52x the post's real earnings.
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: The Zernio SocialAccount ID for the connected Facebook Page.
        - name: postId
          in: query
          required: true
          schema: { type: string }
          description: |
            The platform post ID, exactly as returned in platformAnalytics[].platformPostId by
            /v1/analytics: "{pageId}_{postId}", or the bare video ID for Reels.
        - name: metrics
          in: query
          schema: { type: string }
          description: |
            Comma-separated list of monetization metrics. Defaults to both:
              - content_monetization_earnings
              - monetization_approximate_earnings

            content_monetization_earnings always carries unit "micro_amount" plus an ISO 4217
            "currency". monetization_approximate_earnings is always a bare number, so its unit is
            "unspecified" and its "currency" is null. The two are on different scales and are not
            comparable to each other. Any other metric name is rejected with 400.
      responses:
        '200':
          description: Lifetime per-post monetization earnings
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/FacebookPostEarningsResponse'
              examples:
                served:
                  summary: 'Both metrics served, each at its own scale'
                  value:
                    success: true
                    accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
                    postId: "123456789_987654321"
                    platform: "facebook"
                    period: "lifetime"
                    metrics:
                      content_monetization_earnings:
                        total: 1234000000
                        unit: "micro_amount"
                        currency: "USD"
                      monetization_approximate_earnings:
                        total: 1234
                        unit: "unspecified"
                        currency: null
                    dataDelay: "Meta restates monetization earnings after the fact. Per-post earnings are lifetime cumulative: they are the total since publication, not earnings within a date range, and must not be summed across dates."
                partiallyUnavailable:
                  summary: 'One metric served, one with no bucket returned by Meta'
                  value:
                    success: true
                    accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
                    postId: "123456789_987654321"
                    platform: "facebook"
                    period: "lifetime"
                    metrics:
                      monetization_approximate_earnings:
                        total: 0
                        unit: "unspecified"
                        currency: null
                    unavailableMetrics:
                      - metric: "content_monetization_earnings"
                        reason: "no_data"
                        message: "Meta returned no data for this monetization metric."
                    dataDelay: "Meta restates monetization earnings after the fact. Per-post earnings are lifetime cumulative: they are the total since publication, not earnings within a date range, and must not be summed across dates."
        '400':
          description: |
            Bad request. Common cases:
              - Invalid accountId format, or a metric name that is not a monetization metric
              - A "breakdown" param was supplied (breakdown dimensions are not exposed)
              - A "since", "until", "period", or "metricType" param was supplied (this endpoint returns a lifetime total and takes no date range)
              - Account has no Page access token (metadata.pageAccessToken missing)
              - 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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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.
        Pass videoId to get the audience profile of a single video instead of the whole channel.
        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. YouTube suppresses demographics
        for videos with too few signed-in views, so low-traffic videos can return empty breakdowns.
        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: videoId
          in: query
          schema: { type: string }
          description: |
            YouTube video ID. When provided, demographics are scoped to this single video
            (must belong to the connected channel; otherwise 404 video_not_found).
        - 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, or to the video's
            publish date (lifetime) when videoId is provided.
        - name: endDate
          in: query
          schema: { type: string, format: date }
          description: |
            End date (YYYY-MM-DD). Defaults to 3 days ago, the newest fully finalized day
            (YouTube finalizes analytics with a ~3-day delay). An explicit endDate is honored
            up to today: days inside the delay window are provisional and may still be revised
            by YouTube (see provisionalSince in the response).
      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."
                singleVideo:
                  summary: Demographics scoped to one video (videoId provided)
                  value:
                    success: true
                    accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
                    platform: "youtube"
                    videoId: "dQw4w9WgXcQ"
                    title: "Product launch teaser"
                    publishedAt: "2026-06-01T09:00:00.000Z"
                    demographics:
                      age:
                        - dimension: "25-34"
                          value: 41.2
                      gender:
                        - dimension: "male"
                          value: 75.0
                        - dimension: "female"
                          value: 25.0
                      country:
                        - dimension: "MX"
                          value: 394
                        - dimension: "AR"
                          value: 181
                    dateRange:
                      startDate: "2026-06-01"
                      endDate: "2026-07-13"
                    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, or the video does not exist / does not belong to this YouTube channel'
          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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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
        '400': { description: Invalid request }
        '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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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"
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: Group name already exists }
    delete:
      x-resource-group: "accounts"
      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"
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/media/presign:
    post:
      x-resource-group: "publishing"
      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
                    - audio/mpeg
                    - audio/mp4
                    - audio/aac
                    - audio/ogg
                    - audio/wav
                    - audio/webm
                    - audio/x-m4a
                  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, unsupported contentType, or size out of range)'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/reddit/search:
    get:
      x-resource-group: "engagement"
      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': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: |
            No active Reddit account with this ID is available to the API key.
            It may have been disconnected or deleted, or it belongs to a profile
            the key cannot access. Re-connecting an account issues a NEW account
            ID, so an ID stored from before a reconnect will not resolve.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /v1/reddit/feed:
    get:
      x-resource-group: "engagement"
      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': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: |
            No active Reddit account with this ID is available to the API key.
            It may have been disconnected or deleted, or it belongs to a profile
            the key cannot access. Re-connecting an account issues a NEW account
            ID, so an ID stored from before a reconnect will not resolve.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '429': { $ref: '#/components/responses/RateLimited' }
  /v1/billing:
    get:
      x-resource-group: "billing"
      operationId: getBilling
      tags: [Usage]
      summary: Account billing snapshot (plan, cycle, balance, caps, status)
      description: |
        The billing "wallet/statement" view: current plan, billing cycle,
        accrued balance + remaining credits this period, spend caps, and
        payment / access status. This is the billing half of the legacy
        `/v1/usage-stats` snapshot — the per-product consumption half is metering
        and lives on `GET /v1/usage`.

        Usage-based (Metronome) accounts get a populated `balance`; legacy Stripe
        accounts get `balance: null` plus a deprecated `legacy.limits` block and,
        when payment-blocked, `status.openInvoiceUrl` / `status.declineReason`.
      responses:
        '200':
          description: Billing snapshot
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/BillingSnapshot'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/billing/x-pricing:
    get:
      x-resource-group: "billing"
      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:
      x-resource-group: "billing"
      operationId: getUsage
      tags: [Usage]
      summary: Usage snapshot (default) or billed-spend metering (with params)
      description: |
        Dual-mode endpoint, selected by query params — fully backward
        compatible:

        **Without metering params (the default):** the plan / quota / usage
        snapshot — plan name, billing period, limits, usage counts, access
        state. Identical to `GET /v1/usage-stats`. Existing integrations keep
        working unchanged.

        **With `range`, `granularity`, `from`, or `to`:** usage METERING —
        billed spend (USD) by product family (`accounts`, `numbers`, `calls`,
        `sms`, `dlc`, `xApi`, `credits`, `other`) over the window, at
        `day` / `month` / `total` granularity, from Metronome's invoice
        breakdown (the CHARGE view — always reconciles with what gets billed).
        Also served at `GET /v1/usage/daily`. Usage-based accounts only —
        legacy Stripe accounts get `{ "supported": false, "days": [] }`.

        For per-domain consumption *volumes* use `GET /v1/usage/calls` and
        `GET /v1/usage/sms`. For the billing statement (balance, credits,
        caps, payment status) use `GET /v1/billing`.
      parameters:
        - name: reconcile
          in: query
          required: false
          schema:
            type: boolean
          description: |
            Snapshot mode only. For Stripe subscription users, `true` forces a
            subscription reconciliation pass even when cached plan data looks
            complete.
        - name: range
          in: query
          required: false
          schema:
            type: string
            enum: [cycle, prev-cycle, 7d, 14d, 30d, 3mo, 12mo, custom]
            default: cycle
          description: |
            Window to report. `cycle` / `prev-cycle` resolve to the customer's
            real billing-period bounds (falling back to a trailing 30 days when
            no invoice exists yet); `7d`…`12mo` are trailing windows; `custom`
            uses `from` / `to`.
        - name: from
          in: query
          required: false
          schema: { type: string, format: date }
          description: Inclusive start (UTC date). Required when `range=custom`.
        - name: to
          in: query
          required: false
          schema: { type: string, format: date }
          description: Inclusive end (UTC date). Required when `range=custom`. Max span 366 days.
        - name: granularity
          in: query
          required: false
          schema:
            type: string
            enum: [day, month, total]
            default: day
          description: |
            Bucketing of the `days` series: `day` (one row per UTC day),
            `month` (one row per calendar month, dated to the 1st), or `total`
            (no series — read `totals`). Does not affect `totals`.
      responses:
        '200':
          description: |
            Snapshot (no metering params) or billed spend by product over the
            window (with metering params).
          content:
            application/json:
              schema:
                oneOf:
                  - $ref: '#/components/schemas/UsageStats'
                  - $ref: '#/components/schemas/UsageMetering'
        '400': { description: Invalid query parameter }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/usage-stats:
    get:
      x-resource-group: "billing"
      operationId: getUsageStats
      tags: [Usage]
      summary: Get plan and usage snapshot (plan, limits, payment status)
      deprecated: true
      description: |
        The plan / quota / payment-status snapshot: current plan name, billing
        period, plan limits, usage counts, and access state. Identical to a
        bare `GET /v1/usage` call (this path is its deprecated alias). For
        billed spend by product, call `GET /v1/usage` with `range` /
        `granularity` params. The statement view (balance, credits, caps,
        payment status) lives at `GET /v1/billing`.

        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:
      x-resource-group: "billing"
      operationId: getCallsUsage
      tags: [Usage]
      summary: Calling usage and 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:
      x-resource-group: "billing"
      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:
      x-resource-group: "publishing"
      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'
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 500, default: 10 }
          description: 'Page size. Values above the maximum return 400 rather than being clamped.'
        - 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 }
          description: 'Filter posts to a specific profile (24-char hex ObjectId). Omit it, or send `all` or an empty value, to list posts across every profile.'
        - name: createdBy
          in: query
          schema: { type: string }
          description: 'Filter posts to those created by a specific team user (24-char hex ObjectId).'
        - name: dateFrom
          in: query
          schema: { type: string, format: date }
          description: 'Zero-padded YYYY-MM-DD, or a full ISO 8601 datetime. An empty value means no date filter; any other malformed value returns 400.'
        - name: dateTo
          in: query
          schema: { type: string, format: date }
          description: 'Zero-padded YYYY-MM-DD, or a full ISO 8601 datetime. An empty value means no date filter; any other malformed value returns 400.'
        - 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
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      x-resource-group: "publishing"
      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'
                          - $ref: '#/components/schemas/SlackPlatformData'
                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 the TikTok platforms sent in the same request. Merged into each platform''s platformSpecificData, with platform-specific settings taking precedence.'
                facebookSettings:
                  $ref: '#/components/schemas/FacebookSettings'
                  description: 'Root-level Facebook settings applied to the Facebook platforms sent in the same request. Merged into each platform''s platformSpecificData.facebookSettings, 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"
              facebookTextBackground:
                summary: Facebook colored-background text post
                description: |
                  Renders the post as large text on a Facebook-defined colored background.
                  Text-only: mediaItems and carouselCards are rejected, and contentType
                  must not be story or reel.
                value:
                  content: "We just shipped something big."
                  platforms:
                    - platform: facebook
                      accountId: "64e1f0a9e2b5af0012ab34cd"
                  publishNow: true
                  facebookSettings:
                    textFormatPresetId: "1881421442117417"
              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. Distinguish by the `code` field:
            - `ACCOUNT_DISCONNECTED` — a target account exists but its platform connection is no longer active (token expired or revoked, or the account was disconnected). Reconnect the account, then refresh account IDs from `GET /v1/accounts` (accounts report their connection state via `isActive`). The disconnect itself is also emitted as the `account.disconnected` webhook event.
            - `PROFILE_OVER_LIMIT` — a target account belongs to a profile beyond the plan's profile limit.
            - No `code` — a target `accountId` does not belong to the authenticated user (or is outside the API key's profile scope).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string, example: 'Account 6a0f6d2e520992756d96bb6c (facebook "My Page") is disconnected and cannot be posted to. Facebook tokens expired. Please reconnect your Facebook account. After reconnecting, refresh your account IDs from GET /v1/accounts.' }
                  code: { type: string, enum: [ACCOUNT_DISCONNECTED, PROFILE_OVER_LIMIT], description: Stable machine-readable cause. Absent for ownership failures. }
        '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 (25 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:
      x-resource-group: "publishing"
      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 profiles: LinkedIn has no listing API for personal profiles, so a `url` is REQUIRED and imports that single post. Pass any LinkedIn post URL (`linkedin.com/posts/…`, `linkedin.com/feed/update/urn:li:activity:…`) or a `urn:li:share:…` / `urn:li:ugcPost:…` URN. Works for posts published outside Zernio and before the account was connected, any age; the post must be authored by the connected member. Imported posts return full analytics (impressions, reach, reactions, comments, reshares, saves) and keep refreshing on the background analytics cycle, but carry no content/media (LinkedIn does not expose them for personal profiles).

        `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:
      x-resource-group: "publishing"
      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"
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Forbidden
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404': { $ref: '#/components/responses/NotFound' }
    put:
      x-resource-group: "publishing"
      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.

        Non-draft updates run the same per-platform validation as post creation (media requirements, platform-specific
        field rules, etc.) against the resulting platforms, returning 400 on failure.
      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
                        description: 'A <platform>Settings namespace (e.g. facebookSettings, tiktokSettings) omitted from the request is preserved from the stored post. Sending the key replaces the whole namespace; it is not deep-merged.'
                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 the TikTok platforms sent in the same request. Merged into each platform''s platformSpecificData, with platform-specific settings taking precedence. Returns 400 if sent without a platforms array.'
                facebookSettings:
                  $ref: '#/components/schemas/FacebookSettings'
                  description: 'Root-level Facebook settings applied to the Facebook platforms sent in the same request. Merged into each platform''s platformSpecificData.facebookSettings, with platform-specific settings taking precedence. Returns 400 if sent without a platforms array.'
                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
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Forbidden
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      x-resource-group: "publishing"
      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
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Forbidden
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/posts/bulk-upload:
    post:
      x-resource-group: "publishing"
      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
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: 'Payment required: the account owner has a failed payment. Not returned on dry-run.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
        '404':
          description: Authenticated user not found
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '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:
      x-resource-group: "publishing"
      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
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402':
          description: 'Payment required: the account owner has a failed payment.'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
        '403':
          description: Forbidden
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409':
          description: Post is currently publishing
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '429':
          description: |
            Rate limit exceeded. Possible causes: API rate limit (requests per minute), velocity limit (25 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:
      x-resource-group: "publishing"
      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."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Forbidden
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500':
          description: Platform API deletion failed
  /v1/posts/{postId}/edit:
    post:
      x-resource-group: "publishing"
      operationId: editPost
      tags: [Posts]
      summary: Edit published post
      description: |
        Edit the text of an already-published post. Supported on X (Twitter), Discord,
        Facebook, and Reddit. Each platform enforces its own rules:

        **X (Twitter)**
        - 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)
        - Threads cannot be edited, only single tweets
        - X assigns a NEW post ID on edit, returned as `id`

        **Discord**
        - No time limit and no premium requirement
        - The message ID is unchanged after the edit

        **Facebook**
        - Graph only permits editing a post that the same app created, so this works on
          posts published through Zernio and is rejected for posts created in Meta
          Business Suite / Composer or by another tool
        - Media cannot be swapped, only the message text
        - Reactions, comments, and shares are preserved. The post ID is unchanged

        **Reddit**
        - Self-posts only. A link post has no editable body and is rejected before the write
        - Body only. Reddit exposes no API to edit a post title, ever
        - The post ID is unchanged

        Media edits are not supported on any platform. The post record in Zernio is updated
        with the new content and an edit-history entry.
      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.
                  enum: [twitter, discord, facebook, reddit]
                content:
                  type: string
                  description: The new post text content
            example:
              platform: "facebook"
              content: "Updated post text with corrected information"
      responses:
        '200':
          description: Post edited successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  id:
                    type: string
                    description: >
                      The platform post ID after the edit. X assigns a new ID; Discord,
                      Facebook, and Reddit return the original ID unchanged.
                  url: { type: string, format: uri, description: URL of the edited post }
                  message: { type: string }
              example:
                success: true
                id: "1234567890123456790"
                url: "https://twitter.com/i/web/status/1234567890123456790"
                message: "twitter post edited successfully"
        '400':
          description: >
            Invalid request: platform not supported, post not published, edit window expired,
            not X Premium, or missing content. Also returned when the platform rejects the edit
            with a 4xx, which covers a Reddit link post (no editable body) and a Facebook post
            that was not created by this app.
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Forbidden
        '404': { $ref: '#/components/responses/NotFound' }
        '500':
          description: Platform API edit failed for a reason the platform did not classify.
  /v1/posts/{postId}/update-metadata:
    post:
      x-resource-group: "publishing"
      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."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Forbidden
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '404': { $ref: '#/components/responses/NotFound' }
        '500':
          description: Platform API update failed
  /v1/users:
    get:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      operationId: listProfiles
      tags: [Profiles]
      summary: List profiles
      description: >-
        Returns profiles sorted default-first, then by creation date. Filter
        with name (exact match) and paginate with limit/skip; without those
        params the full list is returned unchanged. 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)."
        - name: name
          in: query
          required: false
          schema: { type: string }
          description: 'Exact-match filter on the profile name. Useful to recover a profile id after an ambiguous create (timeout followed by a 409 on retry).'
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 1000 }
          description: 'Page size. When limit or skip is present, the response includes total and skip (and echoes limit).'
        - name: skip
          in: query
          required: false
          schema: { type: integer, minimum: 0 }
          description: 'Number of profiles to skip, applied after sorting and filtering.'
      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
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      x-resource-group: "accounts"
      operationId: createProfile
      tags: [Profiles]
      summary: Create profile
      description: >-
        Creates a new profile with a name, optional description, and color.
        Names are unique per workspace: a duplicate returns a 409 whose
        details.existingProfileId carries the id of the existing profile.
        Send an Idempotency-Key header to make retries safe: a retried create
        with the same key and body replays the original 201 (same _id)
        instead of conflicting.
      parameters:
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      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 }
        '409': { description: 'A profile with this name already exists (code: profile_name_conflict); details.existingProfileId carries the id of the existing profile. Also returned while a request with the same Idempotency-Key is still processing.' }
        '422': { $ref: '#/components/responses/IdempotencyKeyReused' }
  /v1/profiles/{profileId}:
    get:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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"
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
        '409': { description: "A profile with this name already exists (code: profile_name_conflict)." }
    delete:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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).
        page and limit must be supplied together; out-of-range page/limit values are rejected with 400 rather than silently clamped.
      parameters:
        - name: profileId
          in: query
          schema: { type: string }
          description: Filter accounts by profile ID. Must be a valid ObjectId.
        - 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). Must be provided together with limit to enable server-side pagination; sending only one of the two returns 400. Omit both for all accounts.
        - name: limit
          in: query
          schema: { type: integer, minimum: 1, maximum: 100 }
          description: |
            Page size. Must be provided together with page; sending only one of the two returns 400.
      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
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/accounts/follower-stats:
    get:
      x-resource-group: "analytics"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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, slack, 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"]
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/accounts/{accountId}/whatsapp/register:
    post:
      x-resource-group: "accounts"
      operationId: registerWhatsAppNumber
      tags: [WhatsApp]
      summary: Register a connected WhatsApp number on the Cloud API
      description: |
        Re-runs Meta's Cloud API registration for a WhatsApp account that is already connected.
        Use it when the number has its own two-step verification PIN: the connect flows register
        with a default PIN, Meta rejects that with error 133005, and the number then fails every
        send with the misleading '(#200) You do not have the necessary permission to send messages'
        while the account still shows as connected. The PIN is used for this call only and is not stored.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
          description: The WhatsApp account ID
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                pin:
                  type: string
                  pattern: '^\d{6}$'
                  description: 'The 6-digit two-step verification PIN set on the number. Omit it only if the number has no PIN of its own.'
            example:
              pin: "481902"
      responses:
        '200':
          description: Number registered on the WhatsApp Cloud API
          content:
            application/json:
              schema:
                type: object
                properties:
                  registered: { type: boolean }
                  accountId: { type: string }
                  phoneNumberId: { type: string }
        '400':
          $ref: '#/components/responses/BadRequest'
        '401':
          description: Invalid or expired credentials
        '404':
          description: WhatsApp account not found
        '422':
          description: 'Meta rejected the registration (e.g. PIN mismatch), or the number cannot be registered through the API.'
  /v1/accounts/{accountId}/health:
    get:
      x-resource-group: "accounts"
      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}/follow-status/{userId}:
    get:
      operationId: getInstagramFollowStatus
      tags: [Accounts]
      x-platforms: ["instagram"]
      summary: Check whether an Instagram user follows the account
      description: |
        Resolves the follow relationship between an Instagram user and the connected
        account, plus their public profile counters.

        `userId` is the Instagram-scoped id (IGSID) Meta gives you on a webhook:
        `sender.id` on `message.received`, `comment.author.id` on `comment.received`.

        **Meta only answers for people who have MESSAGED the account.** Commenting grants
        no consent, so a commenter who has never DMed you is unresolvable - that is a
        platform rule, not a limitation of this endpoint. When it cannot be resolved the
        response is still `200` with `isFollower: null` and an `unavailableReason`, because
        "unknown" is a normal state to branch on:

          * `consent_required` - the user has never messaged this account.
          * `dm_access_disabled` - the account owner turned off Instagram Direct API access.
          * `not_messageable` - the id is not a messaging-scoped id.
          * `error` - a transient Graph API failure.

        To gate a comment automation on this, use the automation's `audience` rules instead
        of calling this per comment - they run the same lookup only on comments that
        actually match a keyword, and can ask the commenter to confirm with one tap.

        Answers are cached briefly per (account, user). Pass `refresh=true` right after
        asking someone to follow, so a follow from a moment ago is visible.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string }, description: Instagram account ID }
        - { name: userId, in: path, required: true, schema: { type: string }, description: 'Instagram-scoped user id (IGSID) from a webhook payload' }
        - { name: refresh, in: query, required: false, schema: { type: boolean }, description: Bypass the cache and re-query Meta }
      responses:
        '200':
          description: Follow status (fields are null when Meta would not resolve it)
          content:
            application/json:
              schema:
                type: object
                required: [userId, accountId, isFollower]
                properties:
                  userId: { type: string }
                  accountId: { type: string }
                  isFollower:
                    type: [boolean, "null"]
                    description: 'The user follows this account. Null = unknown, never "no".'
                  isFollowedByAccount:
                    type: [boolean, "null"]
                    description: This account follows the user.
                  followerCount: { type: [integer, "null"] }
                  isVerified: { type: [boolean, "null"] }
                  username: { type: [string, "null"] }
                  name: { type: [string, "null"] }
                  unavailableReason:
                    type: [string, "null"]
                    enum: [consent_required, dm_access_disabled, not_messageable, error, null]
                    description: 'Why the follow relationship could not be resolved. Null when it was.'
              example:
                userId: "1312733120229708"
                accountId: "6977a34e77637c5c857c816c"
                isFollower: true
                isFollowedByAccount: false
                followerCount: 25
                isVerified: false
                username: "to.bias8262"
                name: "Tobi Ma"
                unavailableReason: null
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
  /v1/accounts/{accountId}/tiktok/creator-info:
    get:
      x-resource-group: "accounts"
      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/auth/verify:
    get:
      x-resource-group: "public"
      operationId: verifyCredential
      tags: [API Keys]
      summary: Verify credential
      description: 'Checks whether the bearer credential on this request is valid, without reading any data. Accepts an API key or an OAuth access token. Intended for clients that must validate a credential before use (for example an MCP server verifying an incoming token) so they do not have to call a data endpoint to do it.'
      responses:
        '200':
          description: Credential is valid
          content:
            application/json:
              schema:
                type: object
                properties:
                  valid: { type: boolean }
                  userId: { type: string }
                  authType:
                    type: string
                    enum: [api_key, oauth, session]
                  scope:
                    type: [string, 'null']
                    description: 'Granted OAuth scopes, space-separated. Null for API keys.'
              example:
                valid: true
                userId: "6507a1b2c3d4e5f6a7b8c9d0"
                authType: "oauth"
                scope: "posts:read posts:write accounts:read"
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/api-keys:
    get:
      x-resource-group: "admin-plane"
      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' }
        '403': { $ref: '#/components/responses/ResourceGroupForbidden' }
    post:
      x-resource-group: "admin-plane"
      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
                disabledResourceGroups:
                  type: array
                  items:
                    type: string
                    enum: [publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks]
                  description: >-
                    Resource groups to DISABLE on this key (opt-out denylist).
                    Omit for a legacy full-access key. A key with any group
                    disabled mints with the zrk_ prefix, gets 403 with
                    code=insufficient_permissions and required_group on
                    operations in disabled groups (each operation's group is
                    published as x-resource-group), and can never manage API
                    keys, invites, or member identity. With 'messages'
                    disabled, the key cannot read or send private messages
                    through any API surface and cannot create or edit a
                    webhook subscription broader than itself. Subscriptions
                    that already exist are governed by their own
                    `disabledResourceGroups`, not by this key's. OAuth
                    connector tokens resolve against the same registry, but
                    their groups are not settable yet.
            example:
              name: "No Private Messages Key"
              disabledResourceGroups: ["messages", "contacts", "webhooks"]
      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: "No Private Messages Key"
                  keyPreview: "zrk_12345678...90abcdef"
                  key: "zrk_1234567890abcdef1234567890abcdef1234567890abcdef1234567890abcdef"
                  expiresAt: null
                  createdAt: "2024-01-15T10:30:00Z"
                  scope: "full"
                  profileIds: []
                  permission: "read-write"
                  disabledResourceGroups: ["messages", "contacts", "webhooks"]
        '400': { description: "Invalid request (missing name, invalid scope/permission, or missing profileIds when scope is 'profiles')" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/ResourceGroupForbidden' }
  /v1/api-keys/{keyId}:
    delete:
      x-resource-group: "admin-plane"
      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' }
        '403': { $ref: '#/components/responses/ResourceGroupForbidden' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/me/connected-apps:
    get:
      x-resource-group: "admin-plane"
      operationId: listConnectedApps
      tags: [Connected Apps]
      summary: List connected apps
      description: |
        Returns the OAuth clients (AI assistants and MCP connectors) the authenticated
        user has authorized and that still hold a live token.

        Requires a session or a full-access API key. A profile-scoped API key, a
        restricted (zrk_) API key, or an OAuth access token is rejected with 403: an
        app must not be able to enumerate its sibling authorizations, and connected-app
        management is admin-plane.
      responses:
        '200':
          description: Connected apps
          content:
            application/json:
              schema:
                type: object
                properties:
                  connectedApps:
                    type: array
                    items: { $ref: '#/components/schemas/ConnectedApp' }
              example:
                connectedApps:
                  - clientId: "late_cid_3f9c1a7b2d4e6f80a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718"
                    clientName: "Claude"
                    redirectHost: "claude.ai"
                    scopes: ["posts:read", "posts:write", "accounts:read"]
                    authorizedAt: "2026-07-02T09:14:00Z"
                    lastUsedAt: "2026-07-30T18:02:11Z"
                    tokenCount: 2
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/ResourceGroupForbidden' }
  /v1/me/connected-apps/{clientId}:
    delete:
      x-resource-group: "admin-plane"
      operationId: revokeConnectedApp
      tags: [Connected Apps]
      summary: Revoke connected app
      description: |
        Ends an app's access: invalidates the client's pending authorization codes and
        revokes every live token it holds for the authenticated user. Takes effect on
        the app's next request.

        Idempotent while the authorization is still on record: revoking an app that
        was already revoked returns 200 with `revokedTokens: 0`.

        Requires a session or a full-access API key. A profile-scoped API key, a
        restricted (zrk_) API key, or an OAuth access token is rejected with 403.
      parameters:
        - name: clientId
          in: path
          required: true
          description: 'OAuth client id, as returned by GET /v1/me/connected-apps.'
          schema: { type: string, minLength: 1, maxLength: 200 }
      responses:
        '200':
          description: Revoked
          content:
            application/json:
              schema:
                type: object
                properties:
                  revoked: { type: boolean }
                  clientId: { type: string }
                  revokedTokens: { type: integer, description: 'Access and refresh tokens revoked by this call.' }
                  invalidatedCodes: { type: integer, description: 'Pending authorization codes invalidated by this call.' }
              example:
                revoked: true
                clientId: "late_cid_3f9c1a7b2d4e6f80a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718"
                revokedTokens: 2
                invalidatedCodes: 0
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { $ref: '#/components/responses/ResourceGroupForbidden' }
        '404':
          description: 'The authenticated user has never authorized this client. Error code: oauth_client_not_found.'
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /v1/invite/tokens:
    post:
      x-resource-group: "admin-plane"
      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.

        Returns 403 when a requested profile is not found or not owned, or when
        called with a restricted (zrk_) API key: invite management is admin-plane.
      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': { $ref: '#/components/responses/ResourceGroupForbidden' }

  /v1/connect/{platform}:
    get:
      x-resource-group: "accounts"
      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, slack, 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). For WhatsApp, a Zernio-provisioned number can only be connected on the profile it was provisioned to; connecting from any other profile is rejected with a 409.'
        - name: redirect_url
          in: query
          schema: { type: string, format: uri }
          description: Your custom redirect URL after connection completes. Accepts an http(s) URL, a custom app scheme for mobile deeplinks (e.g. myapp://callback), or a relative path. Result params are appended with the URL API, so an existing query string is preserved. 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.
        - name: loginMethod
          in: query
          schema: { type: string, enum: [instagram_login, facebook_login], default: instagram_login }
          description: |
            Instagram only. Which of the two Instagram connection methods to use. Ignored for every other platform.

            `instagram_login` (the default, and what you get if you omit this): the Instagram Login dialog. The user authorizes their Instagram professional account directly, no Facebook Page required.

            `facebook_login`: the Facebook Login dialog, i.e. "Instagram API with Facebook Login". The user authorizes a Facebook Page that has a linked Instagram professional account, and every API call for that account then runs through the Page. Use this when the customer manages Instagram through a Page and expects the Facebook consent screen. Because the user has to pick which Page to connect, the callback continues at the account-selection step, `/v1/connect/instagram/select-account`.

            `facebook_login` supports `headless=true` like the other selection platforms: the callback redirects to your `redirect_url` with `profileId`, `tempToken`, `platform=instagram`, `step=select_account` and `connect_token`, which you pass into the select-account endpoints to finish. The default `instagram_login` has no selection step, so it connects the account directly.
      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:
      x-resource-group: "accounts"
      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: 'Internal error while connecting the account' }
        '502': { description: 'The platform rejected the token exchange (type: platform_error; an upstream 4xx status is forwarded instead of 502)' }

  /v1/connect/{platform}/ads:
    get:
      x-resource-group: "accounts"
      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). Accepts an http(s) URL, a custom app scheme for mobile deeplinks (e.g. myapp://callback), or a relative path.
        - name: headless
          in: query
          schema: { type: boolean, default: false }
          description: Enable headless mode (same-token platforms only)
        - name: force
          in: query
          schema: { type: boolean, default: false }
          description: |
            Force a fresh OAuth even when an account already exists. Normally the
            endpoint returns `alreadyConnected: true` whenever a connected account
            is found, keying off its active state rather than token liveness.
            Set `force=true` to bypass that and always receivean `authUrl`.
            Completing the returned OAuth refreshes the stored token
            on the existing posting and ads accounts in place.
        - 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:
      x-resource-group: "accounts"
      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 JSON body, invalid accountId format, invalid lengths, or no advertiser found on the account"
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: TikTok Ads account not found
        '500':
          description: Unexpected server error while caching the identity
        '502':
          description: 'TikTok rejected the image upload or the identity creation (type: platform_error; an upstream 4xx status is forwarded instead of 502)'

  /v1/connect/facebook/select-page:
    get:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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&accountId=64e1f0a9e2b5af0012ab34cd&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
        '409':
          description: |
            Reconnect identity mismatch. The OAuth
            was initiated as a `force=true` token-recovery re-auth
            (`GET /v1/connect/{platform}/ads`), but the grant landed on a different
            Facebook user or page than the connected account. The existing account
            is left untouched.
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, enum: [RECONNECT_ACCOUNT_MISMATCH] }
        '500':
          description: Failed to save Facebook connection

  /v1/connect/instagram/select-account:
    get:
      operationId: listInstagramPages
      tags: [Connect]
      summary: List Pages with a linked Instagram account
      description: |
        Completes the `loginMethod=facebook_login` Instagram flow, i.e. "Instagram API with Facebook Login".

        After the user authorizes on Facebook, extract `tempToken` from the redirect params (headless mode adds `step=select_account`) and pass it here to list the Facebook Pages they manage. Only Pages that have a linked Instagram professional account are returned, so an empty array means the user has no eligible Page. Use the X-Connect-Token header if connecting via API key.

        Not used by the default `instagram_login` flow, which creates the account without a selection step.
      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: Long-lived Facebook user access token from the OAuth callback redirect
      security:
        - bearerAuth: []
        - connectToken: []
      responses:
        '200':
          description: Facebook Pages that have a linked Instagram professional account
          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 }
                        access_token: { type: string, description: Page-specific access token }
                        instagram_business_account:
                          type: object
                          description: The Instagram professional account linked to this Page
                          properties:
                            id: { type: string, description: Instagram Business Account ID }
                            username: { type: string }
                            profile_picture_url: { type: string }
              example:
                pages:
                  - id: "811889972008357"
                    name: "My Brand Page"
                    access_token: "EAAxxxxx..."
                    instagram_business_account:
                      id: "17841400649984407"
                      username: "mybrand"
                      profile_picture_url: "https://..."
        '400': { description: 'Missing required parameters (profileId or tempToken)' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: User does not have access to the specified profile }
    post:
      operationId: selectInstagramAccount
      tags: [Connect]
      summary: Select the Page whose Instagram account to connect
      description: |
        Saves the selected Page as an Instagram account connected via Facebook Login. The Page access token becomes the account's access token, so every Instagram call for it runs against the Facebook Graph host.

        One Instagram account per profile: if the profile already has an Instagram account, this replaces it, and picking a different Instagram identity purges the previous account's conversations, external posts and stats.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, pageId, tempToken]
              properties:
                profileId:
                  type: string
                  description: Profile ID from your connection flow
                pageId:
                  type: string
                  description: 'The Facebook Page ID selected by the user, from GET /v1/connect/instagram/select-account'
                tempToken:
                  type: string
                  description: Long-lived Facebook user access token from the OAuth callback redirect
                redirect_url:
                  type: string
                  format: uri
                  description: Optional custom redirect URL to return to after selection
            example:
              profileId: "507f1f77bcf86cd799439011"
              pageId: "811889972008357"
              tempToken: "EAAxxxxx..."
              redirect_url: "https://yourdomain.com/integrations/callback"
      security:
        - bearerAuth: []
        - connectToken: []
      responses:
        '200':
          description: Instagram account connected
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  redirect_url:
                    type: string
                    description: Redirect URL if a custom redirect_url was provided
                  account:
                    type: object
                    properties:
                      accountId: { type: string, description: ID of the created SocialAccount }
                      platform: { type: string, enum: [instagram] }
                      username: { type: string }
                      displayName: { type: string, description: Name of the Facebook Page backing this account }
                      profilePicture: { type: string }
                      isActive: { type: boolean }
                      loginMethod: { type: string, enum: [facebook_login] }
              example:
                message: "Instagram (via Facebook) connected"
                account:
                  accountId: "64e1f0a9e2b5af0012ab34cd"
                  platform: "instagram"
                  username: "mybrand"
                  displayName: "My Brand Page"
                  profilePicture: "https://..."
                  isActive: true
                  loginMethod: "facebook_login"
        '400':
          description: 'Missing required fields, or the selected Page has no linked Instagram professional account'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { $ref: '#/components/responses/PaymentRequired' }
        '403': { description: User does not have access to the specified profile }
        '404': { description: Selected page not found among the pages this token can manage }

  /v1/connect/googlebusiness/locations:
    get:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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, description: 'Human-readable location display name, NOT a resource name. Do not use it to build API paths.' }
                      selectedLocationId: { type: string, description: 'Bare GBP location id. Combine with the GBP account id as accounts/{gbpAccountId}/locations/{selectedLocationId} to form the location resource names that gmb-reviews/batch expects in locationNames.' }
              example:
                message: "Google Business location connected successfully"
                redirect_url: "https://yourdomain.com/integrations/callback?connected=googlebusiness&profileId=507f1f77bcf86cd799439011&accountId=64e1f0a9e2b5af0012ab34cd&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), or the provided accountId is not one of the accounts this connection manages'
        '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:
      x-resource-group: "engagement"
      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 }
                        photoCount: { type: integer, description: 'Number of photos attached to the review (photos only, videos are not counted)' }
                        photos:
                          type: array
                          description: Photos attached to the review by the reviewer
                          items:
                            type: object
                            properties:
                              url: { type: string, format: uri }
                  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"
                    photoCount: 0
                    photos: []
                  - 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
                    photoCount: 0
                    photos: []
                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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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, minLength: 1 }
          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:
      x-resource-group: "accounts"
      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, minLength: 1 }
          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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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, minimum: 1, 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:
      x-resource-group: "accounts"
      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: CATEGORY_UNSPECIFIED, COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, PRODUCT, FOOD_AND_DRINK, MENU, COMMON_AREA, ROOMS, TEAMS, AT_WORK, 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, format: uri, description: Publicly accessible image URL }
                mediaFormat: { type: string, enum: [PHOTO, VIDEO], default: PHOTO }
                description: { type: string, minLength: 1, description: Photo description }
                category:
                  type: string
                  enum: [CATEGORY_UNSPECIFIED, COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, PRODUCT, FOOD_AND_DRINK, MENU, COMMON_AREA, ROOMS, TEAMS, AT_WORK, 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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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
                    required: [name]
                    properties:
                      name: { type: string, minLength: 1 }
                      valueType:
                        type: string
                        enum: [ATTRIBUTE_VALUE_TYPE_UNSPECIFIED, 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 } }
                      uriValues:
                        type: array
                        items:
                          type: object
                          required: [uri]
                          properties:
                            uri: { type: string, format: uri }
                attributeMask:
                  type: string
                  minLength: 1
                  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:
      x-resource-group: "accounts"
      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, minimum: 1, 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:
      x-resource-group: "accounts"
      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, format: uri, 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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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
                  format: uri
                  description: "New action URL. At least one of uri or placeActionType is required (enforced server-side; not modeled as anyOf because required-only anyOf branches break SDK generators)."
                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:
      x-resource-group: "accounts"
      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 }
        '400':
          description: Invalid request
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '401':
          description: Unauthorized
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
    put:
      x-resource-group: "accounts"
      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
                        required: [serviceTypeId]
                        description: "Exactly one of structuredServiceItem or freeFormServiceItem is required per item (enforced server-side; not modeled as oneOf because required-only oneOf branches break SDK generators)."
                        properties:
                          serviceTypeId: { type: string, minLength: 1 }
                          description: { type: string }
                      freeFormServiceItem:
                        type: object
                        required: [category, label]
                        properties:
                          category: { type: string, minLength: 1 }
                          label:
                            type: object
                            required: [displayName]
                            properties:
                              displayName: { type: string, minLength: 1 }
                              description: { type: string }
                              languageCode: { type: string }
                      price:
                        type: object
                        properties:
                          currencyCode: { type: string, minLength: 1 }
                          units: { type: string, minLength: 1 }
                          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:
      x-resource-group: "engagement"
      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 locationReviews array (not grouped by location): each item carries
        the location resource name it belongs to (`name`) plus the review object (`review`),
        whose identity is `review.reviewId`.
        Reviews are requested from Google ordered by `orderBy` (default `updateTime desc`,
        newest first), so callers polling for recent reviews can stop paginating once they
        cross their date window.
        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
                  minItems: 1
                  maxItems: 50
                  items: { type: string, pattern: '^accounts/[^/]+/locations/[^/]+$' }
                  description: "Array of full location resource names (e.g. ['accounts/123/locations/456']). Max 50 per request (Google's batchGetReviews cap); chunk larger sets into multiple requests."
                pageSize:
                  type: integer
                  minimum: 1
                  maximum: 50
                  default: 50
                  description: Number of reviews per page (max 50)
                pageToken:
                  type: string
                  minLength: 1
                  description: Pagination token from previous response
                orderBy:
                  type: string
                  enum: ['updateTime desc', 'rating', 'rating desc']
                  default: 'updateTime desc'
                  description: "Sort order requested from Google. Defaults to 'updateTime desc' (newest first), which allows early-stopping pagination once results cross your date window."
            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: 'LOCATION resource name the review belongs to (accounts/{accountId}/locations/{locationId}) - NOT the review resource name. Use it to attribute the review to a location; the review identity is review.reviewId (full review resource name at review.name).' }
                        review: { type: object, description: "The review object: reviewId (the review's identity), name (full review resource name, accounts/*/locations/*/reviews/*), starRating, comment, reviewer, createTime, updateTime, reviewReply, and reviewMediaItems (review photos/videos; photo items carry thumbnailUrl, video items carry videoUrl)" }
                  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:
      x-resource-group: "engagement"
      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
                  minLength: 1
                  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:
      x-resource-group: "engagement"
      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:
      x-resource-group: "public"
      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:
      x-resource-group: "public"
      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:
      x-resource-group: "accounts"
      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
                  required: [id, urn, name]
                  properties:
                    id: { type: string, minLength: 1 }
                    urn: { type: string, pattern: '^urn:li:organization:' }
                    name: { type: string, minLength: 1 }
                    logoUrl: { type: string, format: uri }
                    vanityName: { type: string }
                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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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&accountId=64e1f0a9e2b5af0012ab34cd&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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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&accountId=64e1f0a9e2b5af0012ab34cd&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:
      x-resource-group: "accounts"
      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/openai-ads/credentials:
    post:
      x-resource-group: "accounts"
      operationId: connectOpenAIAdsCredentials
      tags: [Connect]
      summary: Connect an OpenAI Ads account
      description: |
        Connect an OpenAI Ads account using an API key from ChatGPT Ads Manager.

        The key grants full campaign write access on OpenAI's side (OpenAI does
        not offer a read-only key scope). Zernio uses it to read ads and
        performance, and to create and manage campaigns you set up through
        Zernio (create, status, budget, and cancel). Campaigns created
        directly in ChatGPT Ads Manager can still be managed there.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [apiKey, profileId]
              properties:
                apiKey:
                  type: string
                  description: "API key from ChatGPT Ads Manager (Settings). Grants full read/write access on OpenAI's side; Zernio only ever reads with it."
                profileId:
                  type: string
                  description: Your Zernio profile ID
                state:
                  type: string
                  description: Optional state passthrough for the connect flow.
                redirectUri:
                  type: string
                  format: uri
                  description: Optional URL to redirect to after successful connection
            example:
              apiKey: "sk-ads-xxxxxxxxxxxxxxxxxxxx"
              profileId: "6507a1b2c3d4e5f6a7b8c9d0"
      responses:
        '200':
          description: OpenAI Ads connected successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  accountId: { type: string }
                  adAccountName: { type: string }
                  redirectUrl: { type: string }
              example:
                accountId: "6507a1b2c3d4e5f6a7b8c9d0"
                adAccountName: "Acme US"
        '400': { $ref: '#/components/responses/BadRequest' }
        '401':
          description: "Unauthorized, or the API key could not read an OpenAI ad account (code invalid_credentials)."
        '403':
          description: Ads add-on required.

  /v1/connect/whatsapp/credentials:
    post:
      x-resource-group: "accounts"
      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
                pin:
                  type: string
                  pattern: '^\d{6}$'
                  description: 'The 6-digit two-step verification PIN set on the number. Required if you enabled two-step verification for it, otherwise Meta rejects the Cloud API registration with error 133005 and the number cannot send messages.'
            example:
              profileId: "6507a1b2c3d4e5f6a7b8c9d0"
              accessToken: "EAABsbCS...your-system-user-token"
              wabaId: "123456789012345"
              phoneNumberId: "987654321098765"
              pin: "481902"
      responses:
        '200':
          description: WhatsApp connected successfully
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  registrationWarning:
                    type: string
                    description: 'Present when the account was created but Meta rejected the Cloud API registration. The number cannot send messages until this is resolved.'
                  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. Missing fields, a `pin` that is not 6 digits, 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
        '409':
          description: 'The phone number is a Zernio-provisioned number pinned to a different profile. Connect it from that profile, or move it first with PATCH /v1/whatsapp/phone-numbers/{id}/profile.'

  /v1/connect/whatsapp/select-phone-number:
    get:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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' }
        '409':
          description: 'The phone number is a Zernio-provisioned number pinned to a different profile. Connect it from that profile, or move it first with PATCH /v1/whatsapp/phone-numbers/{id}/profile.'
        '500':
          description: Failed to bind phone number
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }

  /v1/connect/telegram:
    get:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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."
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Code not found }
        '500': { description: Internal error }

  /v1/accounts/{accountId}/facebook-page:
    get:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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:
      x-resource-group: "analytics"
      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, pattern: '^urn:li:(share|ugcPost|activity):[0-9]+$' }
          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: integer, minimum: 0, default: 0 }
          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': { $ref: '#/components/responses/BadRequest' }
        '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:
      x-resource-group: "accounts"
      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"
                urn: "urn:li:organization:12345678"
                name: "Acme Corporation"
                vanityName: "acme-corp"
      responses:
        '200':
          description: Account updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  accountType: { type: string, enum: [personal, organization] }
                  accountName: { type: string }
              example:
                message: "LinkedIn account updated successfully"
                accountType: "organization"
                accountName: "Acme Corporation"
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }

  /v1/accounts/{accountId}/linkedin-mentions:
    get:
      x-resource-group: "accounts"
      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' }
        '403':
          description: The account holds no organization role that can resolve person mentions
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
              examples:
                noPostingRole:
                  value: { error: "LinkedIn blocked this lookup. Your account has no company page where it can create posts, which LinkedIn requires to resolve a person's profile URL. Ask for an Admin or Content Admin role on one of your pages, or mention a company instead." }
        '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:
      x-resource-group: "analytics"
      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/publishing-limit:
    get:
      x-resource-group: "accounts"
      operationId: getInstagramPublishingLimit
      tags: [Instagram]
      summary: Get Instagram publishing limit
      description: |
        Returns the account's remaining content-publishing quota for Instagram's rolling
        24-hour window, so you can pace publishing and warn before the cap is reached.

        `quotaUsage` counts containers published since the start of the window.
        Always compare against the returned `quotaTotal` rather than hardcoding a number:
        Meta's prose documentation and the live API disagree on the value, and the live
        value is authoritative.
      parameters:
        - name: accountId
          in: path
          required: true
          description: The ID of the Instagram account
          schema: { type: string }
      responses:
        '200':
          description: Remaining publishing quota for the rolling window
          content:
            application/json:
              schema:
                type: object
                properties:
                  quotaUsage: { type: integer, description: Containers published so far in the current window }
                  quotaTotal: { type: integer, description: Maximum containers publishable per window }
                  quotaDurationSeconds: { type: integer, description: Length of the rolling window in seconds }
              example:
                quotaUsage: 3
                quotaTotal: 50
                quotaDurationSeconds: 86400
        '400': { description: Not an Instagram account }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
        '502': { description: Instagram rejected the request }

  /v1/accounts/{accountId}/instagram/stories/{storyId}/insights:
    get:
      x-resource-group: "analytics"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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 }
    post:
      x-resource-group: "accounts"
      operationId: createPinterestBoard
      tags: [Connect]
      summary: Create Pinterest board
      description: Creates a new board on the connected Pinterest account. The returned board ID can be used immediately as `platformSpecificData.boardId` when creating a Pinterest post.
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name:
                  type: string
                  description: Name of the board
                description:
                  type: string
                  description: Board description
                privacy:
                  type: string
                  enum: [PUBLIC, PROTECTED, SECRET]
                  default: PUBLIC
                  description: Board privacy setting
            example:
              name: "Summer Recipes"
              description: "My favorite summer recipes"
              privacy: "PUBLIC"
      responses:
        '201':
          description: Board created
          content:
            application/json:
              schema:
                type: object
                properties:
                  board:
                    type: object
                    properties:
                      id: { type: string }
                      name: { type: string }
                      description: { type: string }
                      privacy: { type: string }
                      url: { type: string }
              example:
                board:
                  id: "345678901234567890"
                  name: "Summer Recipes"
                  description: "My favorite summer recipes"
                  privacy: "PUBLIC"
        '400': { description: Invalid request or not a Pinterest account }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
        '502': { description: Pinterest rejected the request (e.g. duplicate board name) }

  /v1/accounts/{accountId}/youtube-playlists:
    get:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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, minLength: 1 }
                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:
      x-resource-group: "accounts"
      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, or raise limit to enumerate an
        account with more than 100 locations.
      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".
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 500, default: 100 }
          description: Max locations to return (default 100, max 500). Raise it to enumerate an account with more than 100 locations; for accounts with thousands, use search/filter instead.
      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
        '400': { description: Invalid query parameter (e.g. limit out of range) }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
    put:
      x-resource-group: "accounts"
      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, minLength: 1 }
                googleAccountId:
                  type: string
                  minLength: 1
                  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, or the provided googleAccountId is not one of the accounts this connection manages' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }

  /v1/accounts/{accountId}/gmb-locations/assign:
    post:
      x-resource-group: "accounts"
      operationId: assignGoogleBusinessLocation
      tags: [Connect]
      summary: Assign GBP location to another profile
      description: >
        Connect a Google Business location onto a DIFFERENT profile by reusing the
        OAuth grant from an already-connected GBP account — no browser, no
        re-authorization. Built for agencies whose single Google account has manager
        access to many client locations and who run one profile per client: connect
        one location the normal way (browser OAuth), then bulk-assign the rest onto
        each client's profile via this endpoint. The path `accountId` is a SOURCE
        connected GBP account (the token holder); the body `profileId` is the TARGET
        profile. Returns 409 if the target profile already has a Google Business
        connection (switch its location with PUT gmb-locations instead).
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
          description: A source connected GBP account whose OAuth grant is reused.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, selectedLocationId]
              properties:
                profileId:
                  type: string
                  description: Target profile to connect the location onto.
                selectedLocationId:
                  type: string
                  description: The Google Business location ID to assign (e.g. "locations/123").
                googleAccountId:
                  type: string
                  description: >
                    Optional but recommended. The Google Business Account resource name
                    ("accounts/123") that owns the location (from GET gmb-locations). When
                    provided the location is resolved directly instead of by enumerating the
                    account, required for accounts with many locations.
            example:
              profileId: "507f1f77bcf86cd799439011"
              selectedLocationId: "locations/12345678901234567890"
              googleAccountId: "accounts/123456789"
      responses:
        '200':
          description: Location assigned to the target profile
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  account:
                    type: object
                    properties:
                      accountId: { type: string }
                      platform: { type: string }
                      username: { type: string }
                      displayName: { type: string }
                      isActive: { type: boolean }
                      selectedLocationName: { type: string, description: 'Human-readable location display name (e.g. "Snap Fitness Dianella"), NOT a resource name. Do not use it to build API paths.' }
                      selectedLocationId: { type: string, description: 'Bare GBP location id (digits only). Combine with the GBP account id as accounts/{gbpAccountId}/locations/{selectedLocationId} to form the location resource names that gmb-reviews/batch expects in locationNames.' }
              example:
                message: "Google Business location assigned to profile successfully"
                account:
                  accountId: "6a41f0ab70e2d329a7741307"
                  platform: "googlebusiness"
                  username: "Snap Fitness Dianella"
                  displayName: "Snap Fitness Dianella"
                  isActive: true
                  selectedLocationName: "Snap Fitness Dianella"
                  selectedLocationId: "12345678901234567890"
        '400': { description: 'Invalid body, selected location not found under the Google account, or the provided googleAccountId is not one of the accounts this connection manages' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: 'Payment required, or target profile exceeds plan limit' }
        '404': { description: Source Google Business account not found }
        '409': { description: 'Target profile already has a Google Business connection (use PUT gmb-locations to switch its location)' }

  /v1/accounts/{accountId}/facebook-post-reactions:
    get:
      x-resource-group: "analytics"
      operationId: getFacebookPostReactions
      tags: [Analytics]
      summary: Get Facebook post reactions
      description: |
        Returns the reaction breakdown for a Facebook Page post: a count per reaction type
        plus the overall total.

        The whole breakdown is fetched in a single Graph call. Note that the post analytics
        endpoint reports only an aggregate reaction count (surfaced there as `likes`), so use
        this endpoint when you need per-type counts.
      parameters:
        - name: accountId
          in: path
          required: true
          description: The ID of the Facebook Page account
          schema: { type: string }
        - name: postId
          in: query
          required: true
          description: The Facebook post ID
          schema: { type: string }
          example: "1234567890_9876543210"
      responses:
        '200':
          description: Reaction breakdown for the post
          content:
            application/json:
              schema:
                type: object
                properties:
                  accountId: { type: string }
                  platform: { type: string, example: facebook }
                  username: { type: string }
                  postId: { type: string }
                  total: { type: integer, description: Total reactions across all types }
                  breakdown:
                    type: object
                    description: Count per reaction type. A type with no reactions returns 0.
                    properties:
                      like: { type: integer }
                      love: { type: integer }
                      haha: { type: integer }
                      wow: { type: integer }
                      sad: { type: integer }
                      angry: { type: integer }
                      care: { type: integer }
                  lastUpdated: { type: string, format: date-time }
              example:
                accountId: "60f7a1b2c3d4e5f6a7b8c9d0"
                platform: "facebook"
                username: "My Page"
                postId: "1234567890_9876543210"
                total: 28
                breakdown: { like: 10, love: 5, haha: 4, wow: 3, sad: 2, angry: 1, care: 3 }
                lastUpdated: "2026-07-09T09:43:50.000Z"
        '400': { description: 'Invalid accountId format, not a Facebook account, or missing postId parameter' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
        '502': { description: Facebook rejected the request }

  /v1/accounts/{accountId}/reddit-subreddits:
    get:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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, minLength: 1 }
            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-subreddits/{subreddit}/rules:
    get:
      x-resource-group: "accounts"
      operationId: getSubredditRules
      tags: [Connect]
      summary: Get subreddit rules
      description: |
        Returns a subreddit's posting rules plus Reddit's site-wide rules, so you can check
        them before submitting and avoid a removal.

        Use this alongside `POST /v1/tools/validate/subreddit`, which only confirms that a
        subreddit exists and reports its basic posting settings.
      parameters:
        - name: accountId
          in: path
          required: true
          description: The ID of the Reddit account
          schema: { type: string }
        - name: subreddit
          in: path
          required: true
          description: Subreddit name (without the "r/" prefix)
          schema: { type: string }
          example: "webdev"
      responses:
        '200':
          description: Subreddit and site rules
          content:
            application/json:
              schema:
                type: object
                properties:
                  rules:
                    type: array
                    items:
                      type: object
                      properties:
                        kind: { type: string, description: "Scope of the rule: 'link', 'comment', or 'all'" }
                        shortName: { type: string, description: Short rule title shown in the subreddit sidebar }
                        description: { type: string, description: Full rule text }
                        violationReason: { type: string, description: Reason shown to a user when the rule is enforced }
                        createdUtc: { type: number, description: Unix timestamp when the rule was created }
                        priority: { type: integer, description: Display order of the rule }
                  siteRules:
                    type: array
                    description: Reddit's site-wide content policy rules
                    items: { type: string }
              example:
                rules:
                  - kind: "link"
                    shortName: "No self-promotion"
                    description: "Posts that primarily promote your own product will be removed."
                    violationReason: "Self-promotion"
                    createdUtc: 1611234567
                    priority: 0
                siteRules:
                  - "Spam"
                  - "Personal and confidential information"
        '400': { description: Not a Reddit account }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account or subreddit not found }
        '502': { description: Reddit was unreachable or returned an unclassified error. Reddit 4xx statuses are forwarded as-is. }

  /v1/accounts/{accountId}/reddit-vote:
    post:
      x-resource-group: "engagement"
      operationId: voteRedditThing
      tags: [Connect]
      summary: Vote on a Reddit post or comment
      description: |
        Cast, change, or clear the connected account's vote on a Reddit post or comment.

        **Reddit requires that votes be cast by humans.** Reddit's API terms permit a client
        to proxy a human's action one-for-one, and prohibit a bot from deciding how to vote
        or from amplifying a human's vote. Call this endpoint only in direct response to an
        explicit action by the account owner. Automated or agent-decided voting is
        vote manipulation and puts API access at risk.
      parameters:
        - name: accountId
          in: path
          required: true
          description: The ID of the Reddit account casting the vote
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [thingId, direction]
              properties:
                thingId:
                  type: string
                  description: >
                    Reddit fullname of the target. Prefix "t3_" for a post and "t1_" for a
                    comment. A bare id with no prefix is treated as a post ("t3_").
                  example: "t3_abc123"
                direction:
                  type: integer
                  description: "1 to upvote, -1 to downvote, 0 to clear an existing vote"
                  enum: [1, 0, -1]
            example:
              thingId: "t3_abc123"
              direction: 1
      responses:
        '200':
          description: Vote registered
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
              example: { success: true }
        '400': { description: "Not a Reddit account, or invalid thingId/direction" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
        '502': { description: Reddit was unreachable or returned an unclassified error. Reddit 4xx statuses are forwarded as-is. }

  /v1/accounts/{accountId}/reddit-flairs:
    get:
      x-resource-group: "accounts"
      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, minLength: 1 }
          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 }
    post:
      x-resource-group: "accounts"
      operationId: setRedditPostFlair
      tags: [Connect]
      summary: Set Reddit post flair
      description: |
        Applies a flair to a post the connected account already published. Use the GET on this
        path to list the available `flairTemplateId` values for the subreddit.

        Flair can also be set at submit time by passing `flairId` in `platformSpecificData`
        when creating the post. This endpoint is for changing it afterwards.

        The subreddit must allow users to select their own post flair. Setting flair on
        another user's post requires moderator permissions, which Zernio does not request.
      parameters:
        - name: accountId
          in: path
          required: true
          description: The ID of the Reddit account that owns the post
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [subreddit, postId, flairTemplateId]
              properties:
                subreddit: { type: string, minLength: 1, description: Subreddit name (without the "r/" prefix) }
                postId: { type: string, minLength: 1, description: 'Reddit post id, with or without the t3_ prefix' }
                flairTemplateId: { type: string, minLength: 1, description: Flair template id from the GET on this path }
                text: { type: string, description: "Optional override text, only for editable flair templates" }
            example:
              subreddit: "webdev"
              postId: "t3_abc123"
              flairTemplateId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
      responses:
        '200':
          description: Flair applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
              example: { success: true }
        '400': { description: "Not a Reddit account, or missing subreddit/postId/flairTemplateId" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
        '502': { description: "Reddit was unreachable or returned an unclassified error. Reddit 4xx statuses (e.g. subreddit does not allow user flair selection) are forwarded as-is." }

  /v1/accounts/{accountId}/slack-settings:
    get:
      x-resource-group: "accounts"
      operationId: getSlackSettings
      tags: [Accounts]
      summary: Get Slack account settings
      description: 'Returns the connected Slack channel details and the default message identity (name and avatar shown as the author on every post, with Slack''s APP badge). The identity applies to messages only; the app''s own Slack profile is global and cannot be changed per workspace.'
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      responses:
        '200':
          description: Slack account settings
          content:
            application/json:
              schema:
                type: object
                properties:
                  account:
                    type: object
                    properties:
                      _id: { type: string }
                      platform: { type: string, example: slack }
                      displayName: { type: [string, "null"] }
                      channelId: { type: [string, "null"] }
                      channelName: { type: [string, "null"] }
                      channelType: { type: [string, "null"], description: 'public or private' }
                      teamId: { type: [string, "null"] }
                      teamName: { type: [string, "null"] }
                      defaultUsername: { type: [string, "null"] }
                      defaultIconUrl: { type: [string, "null"] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
    patch:
      x-resource-group: "accounts"
      operationId: updateSlackSettings
      tags: [Accounts]
      summary: Update Slack account settings
      description: 'Set or clear the default message identity for this channel. Empty string clears a field; per-post platformSpecificData.username/iconUrl still override these defaults.'
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                defaultUsername: { type: string, maxLength: 80, description: 'Author name shown on posts. Empty string clears it.' }
                defaultIconUrl: { type: string, description: 'Author avatar image URL. Empty string clears it.' }
      responses:
        '200':
          description: Updated settings
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }

  /v1/accounts/{accountId}/discord-settings:
    get:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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/accounts/{accountId}/slack-members:
    get:
      x-resource-group: "contacts"
      operationId: listSlackMembers
      tags: [Slack]
      summary: List Slack workspace members
      description: 'Members of the connected Slack workspace that can receive a direct message, for populating a recipient picker. Bots, deactivated members and Slackbot are excluded. Start a DM by passing a member id as `participantId` to POST /v1/inbox/conversations.'
      parameters:
        - name: accountId
          in: path
          required: true
          schema: { type: string }
        - name: query
          in: query
          required: false
          description: 'Case-insensitive filter over display name and handle.'
          schema: { type: string, maxLength: 100 }
        - name: limit
          in: query
          required: false
          schema: { type: integer, minimum: 1, maximum: 2000, default: 50 }
      responses:
        '200':
          description: Workspace members
          content:
            application/json:
              schema:
                type: object
                properties:
                  members:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string, description: 'Slack member id (pass as participantId to start a DM).' }
                        name: { type: string, description: Display name }
                        username: { type: [string, "null"], description: Slack handle }
                        picture: { type: [string, "null"], description: Avatar URL }
              example:
                members:
                  - id: "U01ABCDEF"
                    name: "Maria García"
                    username: "maria"
                    picture: "https://avatars.slack-edge.com/maria.png"
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Slack account not found }

  /v1/discord/dms:
    post:
      x-resource-group: "messages"
      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. }
        '403': { description: "Discord refused 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." }
        '502': { description: Discord was unreachable or returned an unclassified error. }

  /v1/discord/guilds/{guildId}/roles:
    get:
      x-resource-group: "engagement"
      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." }
        '403': { description: Discord refused the request (bot lacks View Channels permission in the guild). }
        '502': { description: Discord was unreachable or returned an unclassified error. }
    post:
      x-resource-group: "engagement"
      operationId: createDiscordGuildRole
      tags: [Discord]
      summary: Create a Discord guild role
      description: |
        Creates a new role in the guild.

        Requires the bot to hold the Manage Roles permission. Guilds that added the Zernio bot
        before role management shipped must re-invite it, because Discord applies the
        permission set at invite time.

        Discord's role hierarchy applies: the bot cannot create a role positioned at or above
        its own highest role, and cannot grant permissions it does not itself hold. Either
        attempt returns a 403 carrying Discord's own error.
      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 }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 100 }
                color: { type: integer, minimum: 0, maximum: 16777215, description: "Decimal color (0 = no color). 0xFF0000 red is 16711680." }
                hoist: { type: boolean, description: Display members with this role separately in the member list }
                mentionable: { type: boolean, description: Allow anyone to @mention this role }
                permissions: { type: string, description: Permissions bitfield as a stringified integer }
            example:
              name: "Moderators"
              color: 16711680
              hoist: true
              mentionable: false
      responses:
        '201':
          description: Role created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/DiscordRole' }
        '400': { description: "Invalid accountId, guildId, or role body." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "Discord account not found, not accessible, or not bound to this guild." }
        '403': { description: "Discord refused the action (bot lacks Manage Roles, or the new role would sit at or above the bot's highest role)." }
        '502': { description: Discord was unreachable or returned an unclassified error. }

  /v1/discord/guilds/{guildId}/roles/{roleId}:
    patch:
      x-resource-group: "engagement"
      operationId: editDiscordGuildRole
      tags: [Discord]
      summary: Edit a Discord guild role
      description: |
        Updates a role's name, color, hoist, mentionable flag, or permission bitfield.
        At least one field must be supplied. Omitted fields are left unchanged.

        Requires the bot to hold Manage Roles, and the target role must sit below the bot's
        highest role. See the create-role operation for the re-invite requirement.
      parameters:
        - { name: guildId, in: path, required: true, schema: { type: string }, description: Discord guild snowflake ID }
        - { name: roleId, in: path, required: true, schema: { type: string }, description: Discord role snowflake ID }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this guild }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              minProperties: 1
              properties:
                name: { type: string, minLength: 1, maxLength: 100 }
                color: { type: integer, minimum: 0, maximum: 16777215 }
                hoist: { type: boolean }
                mentionable: { type: boolean }
                permissions: { type: string, description: Permissions bitfield as a stringified integer }
            example:
              name: "Senior Moderators"
              mentionable: true
      responses:
        '200':
          description: Role updated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/DiscordRole' }
        '400': { description: "Invalid ids, or no fields supplied to edit." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "Discord account not found, not accessible, or not bound to this guild." }
        '403': { description: "Discord refused the action (bot lacks Manage Roles, or the target role sits at or above the bot's highest role)." }
        '502': { description: Discord was unreachable or returned an unclassified error. }
    delete:
      x-resource-group: "engagement"
      operationId: deleteDiscordGuildRole
      tags: [Discord]
      summary: Delete a Discord guild role
      description: |
        Permanently deletes a role from the guild and removes it from every member.
        This cannot be undone.

        Requires the bot to hold Manage Roles, and the target role must sit below the bot's
        highest role.
      parameters:
        - { name: guildId, in: path, required: true, schema: { type: string }, description: Discord guild snowflake ID }
        - { name: roleId, in: path, required: true, schema: { type: string }, description: Discord role 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: Role deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
              example: { success: true }
        '400': { description: "Invalid accountId, guildId, or roleId format." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "Discord account not found, not accessible, or not bound to this guild." }
        '403': { description: "Discord refused the action (bot lacks Manage Roles, or the target role sits at or above the bot's highest role)." }
        '502': { description: Discord was unreachable or returned an unclassified error. }

  /v1/discord/guilds/{guildId}/members:
    get:
      x-resource-group: "contacts"
      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.

        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: { $ref: '#/components/schemas/DiscordGuildMember' }
                  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' }
        '403': { description: 'Discord denied access to the guild members (the bot is no longer in the guild).' }
        '404': { description: Discord account not found or not in this guild. }

  /v1/discord/guilds/{guildId}/members/search:
    get:
      x-resource-group: "contacts"
      operationId: searchDiscordGuildMembers
      tags: [Discord]
      summary: Search Discord guild members
      description: |
        Search guild members whose username or nickname **starts with** the
        query (Discord matches prefixes only, not substrings).

        Cheaper than paginating the full member listing when you already know
        who you are looking for.
      parameters:
        - { name: guildId, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
        - { name: query, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 100 }, description: Username or nickname prefix to match. }
        - { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 1000, default: 25 } }
      responses:
        '200':
          description: Matching guild members.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/DiscordGuildMember' }
        '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}:
    get:
      x-resource-group: "contacts"
      operationId: getDiscordGuildMember
      tags: [Discord]
      summary: Get a Discord guild member
      description: |
        Fetch a single guild member by Discord user id.

        Cheaper than paginating the full member listing when you already know
        who you are looking for.
      parameters:
        - { name: guildId, in: path, required: true, schema: { type: string } }
        - { name: userId, in: path, required: true, schema: { type: string }, description: Discord user snowflake. }
        - { name: accountId, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: The guild member.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data: { $ref: '#/components/schemas/DiscordGuildMember' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: 'Discord account not found, or the user is not a member of this guild.' }

  /v1/discord/guilds/{guildId}/members/{userId}/roles/{roleId}:
    put:
      x-resource-group: "contacts"
      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. }
        '403': { description: "Discord refused the request: bot lacks MANAGE_ROLES, or target role is at or above the bot's highest role." }
        '502': { description: Discord was unreachable or returned an unclassified error. }

    delete:
      x-resource-group: "contacts"
      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. }
        '403': { description: Discord refused the request (permission or hierarchy issue). }
        '502': { description: Discord was unreachable or returned an unclassified error. }

  /v1/discord/channels/{channelId}/messages/{messageId}:
    delete:
      x-resource-group: "engagement"
      operationId: deleteDiscordMessage
      tags: [Discord]
      summary: Delete a Discord channel message
      description: |
        Deletes a message from a channel, for moderation and cleanup. This cannot be undone.

        Deleting a message the bot did not send requires the bot to hold the Manage Messages
        permission, which the Zernio bot requests at install time. Deleting the bot's own
        message needs no extra permission.

        Ownership is verified by resolving the channel's guild and confirming the caller owns
        a Discord account bound to it.
      parameters:
        - { name: channelId, in: path, required: true, schema: { type: string }, description: Discord channel snowflake ID }
        - { name: messageId, in: path, required: true, schema: { type: string }, description: Discord message snowflake ID }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this channel's guild }
      responses:
        '200':
          description: Message deleted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
              example: { success: true }
        '400': { description: "Invalid accountId, channelId, or messageId format." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "Discord account not found, not accessible, or not bound to this channel's guild." }
        '403': { description: "Discord refused the action (bot lacks Manage Messages)." }
        '502': { description: Discord was unreachable or returned an unclassified error. }

  /v1/discord/channels/{channelId}/messages/{messageId}/crosspost:
    post:
      x-resource-group: "engagement"
      operationId: crosspostDiscordMessage
      tags: [Discord]
      summary: Crosspost Discord message
      description: |
        Publishes a message from an announcement channel so it propagates to every server
        following that channel.

        The source channel must be an announcement channel. Calling this on a regular text
        channel returns a 400 before Discord is contacted, because Discord's own error for
        this case is opaque.
      parameters:
        - { name: channelId, in: path, required: true, schema: { type: string }, description: Discord announcement channel snowflake ID }
        - { name: messageId, in: path, required: true, schema: { type: string }, description: Discord message snowflake ID }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this channel's guild }
      responses:
        '200':
          description: Message crossposted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    description: The crossposted Discord message object.
        '400': { description: "Invalid ids, or the channel is not an announcement channel." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "Discord account not found, not accessible, or not bound to this channel's guild." }
        '403': { description: Discord refused the action (bot lacks the required permission). }
        '502': { description: Discord was unreachable or returned an unclassified error. }

  /v1/discord/channels/{channelId}/threads:
    post:
      x-resource-group: "engagement"
      operationId: createDiscordThread
      tags: [Discord]
      summary: Create a Discord public thread
      description: |
        Creates a public thread in a channel. Pass `messageId` to start the thread from an
        existing message, or omit it to create a standalone thread.

        Threads created here are always public. Requires the bot to hold Create Public
        Threads, which the Zernio bot requests at install time.
      parameters:
        - { name: channelId, in: path, required: true, schema: { type: string }, description: Discord channel snowflake ID }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this channel's guild }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [name]
              properties:
                name: { type: string, minLength: 1, maxLength: 100, description: Thread name }
                messageId:
                  type: string
                  description: Optional message snowflake to start the thread from. Omit for a standalone thread.
                autoArchiveDuration:
                  type: integer
                  enum: [60, 1440, 4320, 10080]
                  description: Minutes of inactivity before the thread auto-archives. Discord accepts only these four values.
            example:
              name: "Release 2.4 discussion"
              autoArchiveDuration: 1440
      responses:
        '200':
          description: Thread created.
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: object
                    properties:
                      id: { type: string, description: Thread snowflake ID }
                      name: { type: string }
              example:
                data: { id: "1180000000000000000", name: "Release 2.4 discussion" }
        '400': { description: "Invalid accountId, channelId, messageId, name, or autoArchiveDuration." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: "Discord account not found, not accessible, or not bound to this channel's guild." }
        '403': { description: "Discord refused the action (bot lacks Create Public Threads)." }
        '502': { description: Discord was unreachable or returned an unclassified error. }

  /v1/discord/channels/{channelId}/pins:
    get:
      x-resource-group: "engagement"
      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:
      x-resource-group: "engagement"
      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:
      x-resource-group: "engagement"
      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:
      x-resource-group: "engagement"
      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:
      x-resource-group: "engagement"
      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:
      x-resource-group: "engagement"
      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:
      x-resource-group: "engagement"
      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, no updatable fields beyond accountId provided, or Discord rejected the update (invalid status transition)." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Discord refused the update (bot permissions). }
        '404': { description: Event or Discord account not found. }
        '502': { description: Discord was unreachable or returned an unclassified error. }

    delete:
      x-resource-group: "engagement"
      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:
      x-resource-group: "publishing"
      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', 'false'] }
          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: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Profile not found }
    post:
      x-resource-group: "publishing"
      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:
      x-resource-group: "publishing"
      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:
      x-resource-group: "publishing"
      operationId: deleteQueueSlot
      tags: [Queue]
      summary: Delete schedule
      description: |
        Delete a queue from a profile. Pass queueId to delete a specific queue;
        omit it to delete all queues for the profile.
        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: false
          schema: { type: string }
          description: Queue ID to delete. Omit to delete all queues for the profile
      responses:
        '200':
          description: Queue schedule deleted
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/QueueDeleteResponse'
              example:
                success: true
                deleted: true
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Profile or queue not found }
  /v1/queue/preview:
    get:
      x-resource-group: "publishing"
      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:
      x-resource-group: "publishing"
      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:
      x-resource-group: "webhooks"
      operationId: getWebhookSettings
      tags: [Webhooks]
      summary: List webhooks
      description: Retrieve all configured webhooks for the authenticated user. Supports up to 50 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' }
        '403': { $ref: '#/components/responses/ResourceGroupForbidden' }
    post:
      x-resource-group: "webhooks"
      operationId: createWebhookSettings
      tags: [Webhooks]
      summary: Create webhook
      description: |
        Create a new webhook configuration. Maximum 50 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.

        A restricted (zrk_) API key can only subscribe to events whose resource group
        the key holds; an event outside the key's groups is rejected with 403, so a
        restricted key can never create a subscription broader than itself.

        `disabledResourceGroups` restricts the subscription itself, independently of
        which key or session later reads it. Events in a disabled group are dropped
        before delivery to this endpoint, on live delivery and on every replay path
        (test fire, redelivery, dead-letter requeue), even if they are listed in
        `events`. Omit it to receive everything in `events`, which is how existing
        subscriptions behave. A restricted key's own disabled groups are always
        unioned in.
      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.platform.deleted, post.tiktok.url_resolved, post.external.created, post.external.updated, post.external.deleted, account.connected, account.disconnected, account.ads.initial_sync_completed, message.received, conversation.started, call.received, call.ended, call.failed, call.permission_request, 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.automatic_event, 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, verification.approved, verification.failed]
                  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
                disabledResourceGroups:
                  type: array
                  items:
                    type: string
                    enum: [publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks]
                  description: >-
                    Resource groups this subscription does not receive
                    (opt-out denylist). Omit or send an empty array to receive
                    every event in `events`. Listing a group here drops its
                    events before delivery and on every replay path. Set at
                    creation it applies to everything this subscription ever
                    receives; changed later via PUT it applies to events
                    emitted after the change, with a five-minute tail for
                    events already queued (see that operation). When the caller
                    is a restricted (zrk_) key, that key's own disabled groups
                    are unioned into whatever you send here, so a restricted
                    key can never create a subscription wider than itself.
            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
              createWebhookWithoutMessages:
                summary: Subscription that does not receive private message payloads
                value:
                  name: "Publishing Webhook"
                  url: "https://example.com/webhook"
                  events: ["post.published", "post.failed", "comment.received"]
                  disabledResourceGroups: ["messages", "contacts"]
                  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' }
        '403': { $ref: '#/components/responses/ResourceGroupForbidden' }
    put:
      x-resource-group: "webhooks"
      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.

        A restricted (zrk_) API key can only set `events` to events whose resource
        group the key holds; an event outside the key's groups is rejected with 403.
        It also cannot widen an existing subscription past its own groups.

        `disabledResourceGroups` replaces the subscription's own denylist, which
        applies to delivery regardless of which key or session created it. Send an
        empty array to clear it. A restricted key's own disabled groups are unioned
        into the stored value on every update, so repointing a legacy unrestricted
        subscription with a restricted key also narrows it.

        Timing: the new denylist applies to every event emitted after the update.
        Events already queued for delivery when the update landed were filtered
        against the previous denylist and can still arrive at your endpoint for up
        to five minutes after they were enqueued, because the delivery worker
        trusts a five-minute enqueue-time snapshot before re-checking the
        subscription. Retries beyond that window, dead-letter replays, test fires,
        and redeliveries are all checked against the current denylist.
      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.platform.deleted, post.tiktok.url_resolved, post.external.created, post.external.updated, post.external.deleted, account.connected, account.disconnected, account.ads.initial_sync_completed, message.received, conversation.started, call.received, call.ended, call.failed, call.permission_request, 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.automatic_event, 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, verification.approved, verification.failed]
                  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
                disabledResourceGroups:
                  type: array
                  items:
                    type: string
                    enum: [publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks]
                  description: >-
                    Replaces the subscription's denylist. Send an empty array
                    to clear it and receive every event in `events` again.
                    Omitting the field leaves the current denylist untouched.
                    Applies to events emitted after the update; already-queued
                    events can still deliver for up to five minutes after they
                    were enqueued. When the caller is a restricted (zrk_) key,
                    that key's own disabled groups are unioned back in either
                    way, so a restricted key can neither clear nor widen a
                    subscription past its own groups.
            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' }
        '403': { $ref: '#/components/responses/ResourceGroupForbidden' }
        '404': { description: Webhook not found }
    delete:
      x-resource-group: "webhooks"
      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' }
        '403': { $ref: '#/components/responses/ResourceGroupForbidden' }

  /v1/webhooks/logs:
    get:
      x-resource-group: "webhooks"
      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.

        For a restricted (zrk_) API key, rows for events outside the key's resource
        groups are omitted (`pagination.total` may over-count), and an `event` filter
        naming such an event is rejected with 403. Events blocked by a subscription's
        own `disabledResourceGroups` are dropped before delivery, so they produce no
        log rows for anyone; the exception is the five-minute tail after a denylist
        change, where an already-queued event can still be delivered and logged.
      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' }
        '403': { $ref: '#/components/responses/ResourceGroupForbidden' }

  /v1/webhooks/test:
    post:
      x-resource-group: "webhooks"
      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.

        `webhook.test` belongs to the `webhooks` resource group, so a key with that
        group disabled is rejected with 403, as is a test fire on a subscription that
        lists `webhooks` in its own `disabledResourceGroups` (a 403, not a reported
        delivery failure). Replays of real events (redelivery, dead-letter requeue) run
        the same checks as live delivery, against both the key's groups and the
        subscription's.
      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' }
        '403': { $ref: '#/components/responses/ResourceGroupForbidden' }
        '404': { description: Webhook not found }
        '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:
      x-resource-group: "publishing"
      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:
      x-resource-group: "messages"
      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.

        Instagram and Facebook pre-connect history: when one of these accounts is connected, Zernio replays the DM history the account already holds on Meta, so conversations that began before the account was connected appear here. Up to 500 conversations per account are replayed. The replay runs in the background and can finish after a listing you have already taken, and replayed conversations keep their original lastMessageAt, so they sort into date order rather than appearing at the top. If you mirror this endpoint into your own store, re-run the sweep rather than relying on a single pass at connect time. Replayed history emits no webhooks and is stored as already read, so it never affects unread counts. Threads that Meta refuses to serve are skipped, and an account whose Instagram "connected tools" message access is turned off is not replayed at all.
      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, whatsapp] }
          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
                          description: 'Opaque conversation identifier. Pass it back verbatim to any /v1/inbox/conversations/{conversationId} route; do not assume a fixed format.'
                        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
                        metadata:
                          type: [object, "null"]
                          description: |
                            Ad-click attribution for a conversation that started from a Meta ad.
                            Absent when the conversation did not originate from an ad click.

                            Captured from the referral Meta attaches to the first inbound message
                            after the click, which is the only message that carries it. If the same
                            person later clicks a different ad, the original values are kept, so the
                            first ad wins. One exception on WhatsApp: when Meta omits `ctwa_clid`
                            from that referral, a later Meta automatic event can supply it and
                            refresh `ctwa_captured_at`, so treat `ctwa_captured_at` as the time
                            Zernio stored the value, not the time of the click.

                            Two families of keys, one per surface. They never appear together:

                              - `ctwa_*` is WhatsApp Click-to-WhatsApp. The ad ID is
                                `ctwa_source_id`. There is no `meta_ad_id` on WhatsApp.
                              - `meta_ad_*` is Instagram Click-to-Direct and Facebook Messenger
                                Click-to-Message. The ad ID is `meta_ad_id`. `ctwa_clid` never
                                appears on these platforms.

                            Every key is optional and only the keys Meta supplied are returned, so
                            read defensively. Meta does not send a campaign or ad set ID, so none
                            is exposed here. More keys may be added over time. Treat any key you
                            do not recognise as an opaque string.

                            Key names differ from the `message.received` webhook on purpose. The
                            webhook forwards Meta's referral verbatim (`ad_id`, `source`, `type`)
                            while the stored conversation record uses the prefixed names below.
                            Renaming either side would break existing integrations, so both
                            spellings are kept.
                          properties:
                            ctwa_clid:
                              type: string
                              description: "WhatsApp only. Meta's click identifier, the value to forward to the Meta Conversions API for Business Messaging. Meta omits it on some numbers, so a WhatsApp referral can arrive without it."
                            ctwa_source_id:
                              type: string
                              description: "WhatsApp only. The Meta ad ID the user clicked. This is the WhatsApp equivalent of meta_ad_id."
                            ctwa_source_type:
                              type: string
                              description: "WhatsApp only. What the user clicked, as supplied by Meta (for example ad or post)."
                            ctwa_source_url:
                              type: string
                              description: "WhatsApp only. Meta's URL for the ad that was clicked, normally an fb.me short link."
                            ctwa_headline:
                              type: string
                              description: "WhatsApp only. Headline of the ad creative at click time."
                            ctwa_captured_at:
                              type: string
                              format: date-time
                              description: "WhatsApp only. When Zernio stored this referral. Always present when a WhatsApp referral was captured."
                            meta_ad_id:
                              type: string
                              description: "Instagram and Facebook only. The Meta ad ID the user clicked. Always present when an Instagram or Facebook referral was captured."
                            meta_ad_source:
                              type: string
                              description: "Instagram and Facebook only. Meta-supplied source identifier, for example ADS."
                            meta_ad_type:
                              type: string
                              description: "Instagram and Facebook only. Meta-supplied referral type, for example OPEN_THREAD."
                            meta_ad_ref:
                              type: string
                              description: "Instagram and Facebook only. The ref parameter passed through from the ad creative."
                            meta_ad_title:
                              type: string
                              description: "Instagram and Facebook only. Title of the ad creative at click time."
                            meta_ad_photo_url:
                              type: string
                              description: "Instagram and Facebook only. Image of the ad creative at click time."
                            meta_ad_video_url:
                              type: string
                              description: "Instagram and Facebook only. Video of the ad creative at click time."
                            meta_ad_post_id:
                              type: string
                              description: "Instagram and Facebook only. The organic post the ad promoted, when the ad was a boosted post."
                            meta_ad_product_id:
                              type: string
                              description: "Instagram and Facebook only. The catalogue product the user clicked, for product ads."
                            meta_ad_flow_id:
                              type: string
                              description: "Instagram and Facebook only. The Meta flow the ad launched, for flow ads."
                            meta_ad_captured_at:
                              type: string
                              format: date-time
                              description: "Instagram and Facebook only. When Zernio stored this referral. Always present when an Instagram or Facebook referral was captured."
                  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:
      x-resource-group: "messages"
      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, WhatsApp, SMS, and Slack. Other platforms return PLATFORM_NOT_SUPPORTED.

        Slack: pass a workspace member id as participantId (list them with GET /v1/accounts/{accountId}/slack-members). Zernio opens the DM channel with that member and sends the message; the thread then behaves like any other Slack conversation in the inbox. The member must belong to the connected workspace.

        WhatsApp: this is the endpoint for sending an approved template message to a phone number. Provide templateName, templateLanguage, and templateParams (variable values for the text header, body and dynamic URL buttons, in that order), 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 with the template's approved sample asset. To send a DIFFERENT asset per message (e.g. a distinct invoice PDF for each recipient), pass the headerMedia field with a public link (or a Meta media id); it overrides the sample for that send. 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. Alternatively, WhatsApp Business Accounts eligible for Meta Direct Send can open a conversation with a business-initiated utility text message and no template: pass category: 'utility' together with message (and no templateName). See the category field below.

        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 (X/Twitter only): X's DM API enforces 200 requests per 15 minutes, 1,000 per 24 hours per connected X account, and 15,000 per 24 hours per X developer app (shared across all DM endpoints). These limits do NOT apply to other platforms. WhatsApp sends are governed by Meta's per-number messaging tiers (unique business-initiated conversations per 24 hours) and per-number throughput instead.
      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 and SMS, the recipient phone number in international format (digits, country code included); for Slack, the workspace member id (e.g. U01ABCDEF). 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. Required when category is set (a Direct Send utility message is a text message).'
                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 unless category is used instead (Direct Send). Cannot be combined with category.'
                category:
                  type: string
                  enum: [utility]
                  description: >-
                    WhatsApp only (Meta Direct Send). Combined with message and without
                    templateName, starts the conversation with a business-initiated
                    UTILITY message and no pre-approved template; Meta matches or
                    auto-creates a template asynchronously. The WhatsApp Business
                    Account must be eligible for Direct Send, otherwise the send fails
                    with an error telling you to use an approved message template
                    instead. Cannot be combined with templateName (templates are
                    already categorized at creation). Utility messages only; marketing
                    content is not allowed under this category. Accepted on the JSON
                    body only, not on multipart requests.
                templateLanguage:
                  type: string
                  description: WhatsApp only. Template language code (e.g. en_US).
                templateParams:
                  type: array
                  items: { type: string }
                  description: >-
                    WhatsApp only. Template variable values as one flat array, in the order the
                    variables appear across the whole template: text-header variables first, then
                    body variables, then one value per dynamic URL button (in button 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. Example - a body with
                    {{1}}, {{2}} plus a URL button https://example.com/{{1}} takes three values:
                    [body1, body2, buttonSuffix]. Media headers (image, video, document) are filled
                    automatically from the approved template and take no value here (use headerMedia
                    to override the header asset per send).
                headerMedia:
                  type: object
                  description: >-
                    WhatsApp only. Overrides a media-header template's header asset for THIS send,
                    so a template with an image/video/document header can carry a different asset
                    per message (e.g. each recipient their own invoice PDF). Without it, the
                    template's approved sample asset is sent. Provide exactly one of link or id.
                  required: [type]
                  properties:
                    type:
                      type: string
                      enum: [image, video, document]
                      description: Must match the template header's media type.
                    link:
                      type: string
                      description: Public URL of the asset to send. Must be reachable without auth.
                    id:
                      type: string
                      description: A Meta media id (from the media upload endpoint), as an alternative to link.
                    filename:
                      type: string
                      description: 'Document display name shown to the recipient (e.g. "Factura 0001-123.pdf"). document type only; ignored for image/video.'
          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). For WhatsApp, this is Zernio's internal conversation id (24-character hex) which matches the id returned by the list-conversations endpoint and the conversationId in the message.received and conversation.started webhooks; use it to correlate the created thread with inbound events.
                      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, platform not supported, category combined with templateName or used on a non-WhatsApp account, or the WhatsApp Business Account is not eligible for Direct Send'
          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:
      x-resource-group: "messages"
      operationId: searchInboxConversations
      summary: Search conversations
      description: |
        Search your conversations two ways at once, and get back the matching conversations, most-recent match first:

        - Message text: matches words inside message bodies. Case-insensitive and accent-insensitive, exact tokens only (no substrings, no stemming). Each hit carries up to 3 most-recent matching messages. With direction=outgoing you can collect examples of how you write to customers, for example to teach an AI agent your tone of voice.
        - Contact identity: matches the participant's name, username, or phone number as a case-insensitive substring. These hits have matchCount 0 and an empty matches array.

        A conversation that matches both ways is returned once, carrying its message matches.

        Only platforms whose messages are stored by Zernio are searchable: WhatsApp, SMS, Telegram, Facebook, Instagram, Twitter/X and Reddit. Bluesky conversations are fetched live from the platform and cannot be searched; those accounts are listed in meta.accountsSkipped.
      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 and in the contact's name, username, or phone number"
        - name: direction
          in: query
          schema: { type: string, enum: [incoming, outgoing] }
          description: "Only match messages sent to you (incoming) or by you (outgoing). Contact-identity matching is not applied when this is set."
        - 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, slack] }
          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] }
                            lastMessage: { type: [string, "null"], description: "The conversation's most recent message preview" }
                            lastMessageAt: { type: [string, "null"], format: date-time }
                        matchCount:
                          type: integer
                          description: "Number of matching messages in this conversation. 0 when the conversation matched only on contact identity (name, username, or phone number), not on message text."
                        matches:
                          type: array
                          description: Up to 3 most-recent matching messages (empty for an identity-only match)
                          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:
      x-resource-group: "messages"
      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
                      metadata:
                        type: [object, "null"]
                        description: |
                          Ad-click attribution for a conversation that started from a Meta ad.
                          Absent when the conversation did not originate from an ad click.

                          Captured once, on the first inbound message after the click, and never
                          overwritten. If the same person later clicks a different ad, the
                          original values are kept. Meta only sends the referral on that first
                          message.

                          This operation currently returns only the `meta_ad_*` family, which
                          covers Instagram Click-to-Direct and Facebook Messenger
                          Click-to-Message. WhatsApp Click-to-WhatsApp attribution (the `ctwa_*`
                          keys, where the ad ID is `ctwa_source_id`) is returned by
                          `GET /v1/inbox/conversations` instead.

                          Every key is optional and only the keys Meta supplied are returned, so
                          read defensively. Meta does not send a campaign or ad set ID, so none is
                          exposed here. More keys may be added over time. Treat any key you do not
                          recognise as an opaque string.

                          Key names differ from the `message.received` webhook on purpose. The
                          webhook forwards Meta's referral verbatim (`ad_id`, `source`, `type`)
                          while the stored conversation record uses the prefixed names below.
                          Renaming either side would break existing integrations, so both
                          spellings are kept.
                        properties:
                          meta_ad_id:
                            type: string
                            description: "The Meta ad ID the user clicked. Always present when a referral was captured."
                          meta_ad_source:
                            type: string
                            description: "Meta-supplied source identifier, for example ADS."
                          meta_ad_type:
                            type: string
                            description: "Meta-supplied referral type, for example OPEN_THREAD."
                          meta_ad_ref:
                            type: string
                            description: "The ref parameter passed through from the ad creative."
                          meta_ad_title:
                            type: string
                            description: "Title of the ad creative at click time."
                          meta_ad_photo_url:
                            type: string
                            description: "Image of the ad creative at click time."
                          meta_ad_video_url:
                            type: string
                            description: "Video of the ad creative at click time."
                          meta_ad_post_id:
                            type: string
                            description: "The organic post the ad promoted, when the ad was a boosted post."
                          meta_ad_product_id:
                            type: string
                            description: "The catalogue product the user clicked, for product ads."
                          meta_ad_flow_id:
                            type: string
                            description: "The Meta flow the ad launched, for flow ads."
                          meta_ad_captured_at:
                            type: string
                            format: date-time
                            description: "When Zernio stored this referral. Always present when a referral was captured."
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
        '404':
          description: Conversation not found
    put:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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.

        Instagram and Facebook conversations include history from before the
        account was connected, replayed from Meta. That replay covers the 500
        most recent messages per conversation: a longer thread keeps its newest
        500 and older messages are not retrievable. Messages that arrived after
        the account was connected are unaffected. Replayed messages are stored
        as already read and emit no webhooks.

        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
                                description: 'Direct media link. On Instagram and Facebook this is a signed Meta CDN url that EXPIRES: use it now, do not store it. Persist `refreshUrl` instead.'
                              refreshUrl:
                                type: [string, "null"]
                                description: 'Instagram and Facebook only. Endpoint that resolves this attachment to a working url every time, re-minting it from Meta when the stored one has expired. Safe to store and render indefinitely.'
                              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:
                            $ref: '#/components/schemas/InboxMessageEditHistoryEntry'
                        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:
      x-resource-group: "messages"
      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`).

        **Idempotency:** send an `Idempotency-Key` header to make retries safe
        (e.g. after a client-side timeout where delivery is unknown): same key +
        same body replays the original response (with `Idempotent-Replayed: true`)
        instead of sending the message a second time; same key + different body
        returns 422; a key still in flight returns 409. Works for JSON and
        multipart (file upload) requests alike. Keys are retained for 24 hours.
      tags: [Messages]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: conversationId
          in: path
          required: true
          schema: { type: string }
          description: 'Opaque conversation identifier, accepted verbatim from the list endpoint or from the conversationId on inbox webhooks. Format not to be assumed.'
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      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." }
                category:
                  type: string
                  enum: [utility]
                  description: >-
                    WhatsApp only (Meta Direct Send). Sends this message as a
                    business-initiated UTILITY message without an approved template,
                    for example outside the 24-hour customer service window; Meta
                    matches or auto-creates a template asynchronously. The WhatsApp
                    Business Account must be eligible for Direct Send, otherwise the
                    send fails with an error telling you to use an approved message
                    template instead. Supported only for text messages (link preview
                    ok) and interactive messages (reply buttons, CTA URL buttons,
                    voice-call button, header of text/image/video/document). Cannot
                    be combined with template, attachments, location, or contacts.
                    Utility messages only; marketing content is not allowed under
                    this category. Accepted on the JSON body only, not on multipart
                    requests.
                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.

                    Instagram / Facebook: also mutually exclusive with `template`.
                    A Meta message carries one body shape, so sending both is a 400
                    rather than a silent drop of the buttons.

                    WhatsApp: buttons always render as interactive reply buttons.
                    Only `title` and `payload` are used — `type`, `url`, and `phone`
                    are ignored (WhatsApp has no URL/phone button in this field; use
                    the `interactive` field with `type: cta_url` for a link button).
                    `payload` becomes the button reply ID delivered on the
                    `message.received` webhook when the user taps. To send a simple
                    reply-button message, provide `title` + `payload` and set
                    `type: postback`, e.g.
                    `{ "type": "postback", "title": "Yes", "payload": "yes" }`.
                  items:
                    type: object
                    required: [type, title]
                    properties:
                      type: { type: string, enum: [url, postback, phone], description: "Button type. phone is Facebook only. Ignored on WhatsApp (buttons always render as reply buttons)." }
                      title: { type: string, maxLength: 20, description: Button label (max 20 chars) }
                      url: { type: string, description: URL for url-type buttons (Facebook/Instagram only) }
                      payload: { type: string, description: "Payload for postback-type buttons. On WhatsApp, this is the reply ID returned on the message.received webhook when the button is tapped." }
                      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`. Mutually exclusive with
                    the top-level `buttons` field (sending both is a 400); put the card's
                    buttons on its `elements` instead.

                    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, location requests, voice-call buttons, and
                    commerce messages (single product, product list, catalog, and
                    carousel). 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 the types listed in the enum below.

                    All interactive messages are session messages: they can only be
                    sent inside the 24-hour customer service window opened by the
                    user's last inbound message.

                    Commerce types (`product`, `product_list`, `catalog_message`, and
                    product carousels) require a Meta catalog connected to the
                    WhatsApp Business Account in Commerce Manager. Media carousels
                    (image/video cards) do not need a catalog.

                    For `product`, `body` is optional (WhatsApp renders the product
                    card itself) and `header` is not allowed (the product image is
                    the header). For `product_list`, a `header` with `type: "text"`
                    is required. For `carousel`, top-level `header`/`footer` are not
                    supported; media goes on each card instead.

                    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.

                    For `request_contact_info`, `action` may be omitted (we default it
                    to `{ "name": "request_contact_info" }`). WhatsApp renders a
                    localized share button that cannot be relabelled, so put the reason
                    for asking in `body.text`: this is a consent prompt, and a bare
                    request converts badly. The reply arrives as an inbound `contacts`
                    message with `metadata.contactsOrigin` set to `contact_request`,
                    and we fold the shared number back into the contact automatically.
                    A `contacts` message with origin `other` is a card the user picked
                    from their address book and is NOT proof of their own number.

                    For `catalog_message`, `action` may also be omitted (we default it
                    to `{ "name": "catalog_message" }`).

                    Tap events come back via the `message.received` webhook with
                    `metadata.interactiveType` set to `list_reply` or `nfm_reply`.
                    Carts submitted from commerce messages arrive as `metadata.order`;
                    product inquiries arrive as `metadata.referredProduct`.
                  required: [type]
                  properties:
                    type:
                      type: string
                      enum: [list, cta_url, flow, location_request_message, request_contact_info, voice_call, product, product_list, catalog_message, carousel]
                      description: Which interactive layout to render.
                    header:
                      type: object
                      description: |
                        Optional header shown above the body. Required with
                        `type: "text"` for `product_list`; not allowed for `product`
                        or `carousel`.
                      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
                      description: Required for every type except `product`, where it is optional.
                      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] }
                        - type: object
                          description: 'Contact-info request action. `type` on the parent must be `request_contact_info`. May be omitted entirely; it is defaulted.'
                          required: [name]
                          properties:
                            name: { type: string, enum: [request_contact_info] }
                        - type: object
                          description: |
                            Single-product action. `type` on the parent must be
                            `product`. Requires a Meta catalog connected to the
                            WhatsApp Business Account in Commerce Manager.
                          required: [catalog_id, product_retailer_id]
                          properties:
                            catalog_id: { type: string, description: Meta catalog ID connected to the WhatsApp Business Account. }
                            product_retailer_id: { type: string, description: Retailer ID (SKU) of the product inside the catalog. }
                        - type: object
                          description: |
                            Multi-product action. `type` on the parent must be
                            `product_list`. Requires a Meta catalog connected to the
                            WhatsApp Business Account in Commerce Manager.
                          required: [catalog_id, sections]
                          properties:
                            catalog_id: { type: string, description: Meta catalog ID connected to the WhatsApp Business Account. }
                            sections:
                              type: array
                              minItems: 1
                              maxItems: 10
                              description: 1-10 sections. Total products across all sections cannot exceed 30.
                              items:
                                type: object
                                required: [product_items]
                                properties:
                                  title: { type: string, description: Optional section header. }
                                  product_items:
                                    type: array
                                    minItems: 1
                                    items:
                                      type: object
                                      required: [product_retailer_id]
                                      properties:
                                        product_retailer_id: { type: string, description: Retailer ID (SKU) of the product inside the catalog. }
                        - type: object
                          description: |
                            Catalog-message action. `type` on the parent must be
                            `catalog_message`. May be omitted entirely; it is defaulted
                            to `{ "name": "catalog_message" }`. Requires a Meta catalog
                            connected to the WhatsApp Business Account in Commerce
                            Manager.
                          required: [name]
                          properties:
                            name: { type: string, enum: [catalog_message] }
                            parameters:
                              type: object
                              properties:
                                thumbnail_product_retailer_id: { type: string, description: Optional product whose image is used as the message thumbnail. Falls back to the first catalog item when omitted. }
                        - type: object
                          description: |
                            Carousel action. `type` on the parent must be `carousel`.
                            Carries 2-10 cards, either all product cards
                            (`type: "product"`, all referencing the same `catalog_id`)
                            or media cards (any other `type`, e.g. `cta_url`, with a
                            required image/video `header` on each card). `card_index`
                            (0-9, non-repeating) is auto-filled sequentially when
                            omitted. Product carousels require a Meta catalog connected
                            to the WhatsApp Business Account in Commerce Manager; media
                            carousels do not.
                          required: [cards]
                          properties:
                            cards:
                              type: array
                              minItems: 2
                              maxItems: 10
                              items:
                                type: object
                                additionalProperties: true
                                properties:
                                  card_index: { type: integer, minimum: 0, maximum: 9, description: Card position. Auto-filled sequentially when omitted. }
                                  type: { type: string, description: "`product` for a product card; media cards use `cta_url` or a quick-reply type." }
                                  header: { type: object, description: "Media cards only, required. Carries the card's image or video." }
                                  body: { type: object, description: Optional card body text. }
                                  action: { type: object, description: "Product cards: `{ catalog_id, product_retailer_id }` (required). Media cards: the card's button action (e.g. `cta_url` with `parameters.display_text` and `parameters.url`)." }
                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; for Telegram, the Telegram message ID (both available in message.platformMessageId from webhooks or the list-messages endpoint). On Slack it threads the reply (thread_ts) instead of quoting. Silently ignored on platforms without send-side reply support, including Instagram and Facebook Messenger (Meta''s Send API rejects reply_to on Instagram and does not expose it on Messenger).'
                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; for Telegram, the Telegram message ID (both available in message.platformMessageId from webhooks or the list-messages endpoint). On Slack it threads the reply (thread_ts) instead of quoting. Silently ignored on platforms without send-side reply support, including Instagram and Facebook Messenger (Meta''s Send API rejects reply_to on Instagram and does not expose it on Messenger).'
                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, category combined with a template or attachment, category used on a non-WhatsApp account, or the WhatsApp Business Account is not eligible for Direct Send)'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code:
                    type: string
                    description: "Stable machine-readable reason. PLATFORM_LIMITATION covers a capability the platform does not offer (e.g. Bluesky and Reddit DMs reject media); MISSING_PARTICIPANT means the stored conversation has no recipient to send to."
                    enum: [PLATFORM_LIMITATION, MISSING_PARTICIPANT]
                  platformError:
                    type: object
                    description: "Instagram/Facebook only. Meta's own diagnostic fields for the rejected send, passed through verbatim so you can tell failure classes apart and quote them to Meta. Absent when the failure did not come from Meta."
                    properties:
                      code: { type: integer, description: "Meta error code" }
                      subcode: { type: integer, description: "Meta error_subcode" }
                      fbtraceId: { type: string, description: "Meta fbtrace_id, quote this in a Meta bug report" }
                      type: { type: string, description: "Meta error type (e.g. OAuthException)" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
        '409': { $ref: '#/components/responses/IdempotencyKeyInFlight' }
        '422': { $ref: '#/components/responses/IdempotencyKeyReused' }

  /v1/whatsapp/media/{mediaId}:
    get:
      x-resource-group: "messages"
      operationId: getWhatsAppMedia
      tags: [WhatsApp]
      summary: Download WhatsApp media
      description: |
        Streams the binary for a WhatsApp attachment. This is the endpoint the
        `url` on a WhatsApp `attachments[]` entry points at, in both the
        `message.received` webhook and the List messages response.

        **This is an authenticated endpoint, not a public link.** Send
        `Authorization: Bearer <your API key>` exactly as you would for any other
        call. Passing the URL straight to a browser, an LLM vision API, or a
        no-code "download file" step without the header returns `401`. This is
        the most common integration mistake on this endpoint, and it differs from
        Instagram, Facebook and Telegram, whose `attachments[].url` is a direct
        CDN link that needs no header.

        **Fetch on receipt, not lazily.** WhatsApp media lives in Meta's media
        store, not ours, and it is removed after a limited retention window
        (currently 7 days, and Meta has been dropping some inbound media sooner).
        Once Meta drops it the media is unrecoverable and this endpoint answers
        `400` permanently, so retrying will never succeed. Download and store the
        bytes when the webhook arrives.
      security:
        - bearerAuth: []
      parameters:
        - { name: mediaId, in: path, required: true, schema: { type: string }, description: 'The media id from `attachments[].payload.id`.' }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: 'The WhatsApp account that received the media.' }
      responses:
        '200':
          description: The media binary, streamed with its original content type.
          content:
            application/octet-stream:
              schema: { type: string, format: binary }
        '400':
          description: 'Media is no longer available on WhatsApp servers (expired or deleted by Meta). Permanent, do not retry.'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: 'Account not found, not accessible to the caller, or the media does not belong to it.'
        '502':
          description: Meta could not be reached or returned an unexpected error.

  /v1/inbox/conversations/{conversationId}/messages/{messageId}:
    patch:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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
        - Instagram: Shows "typing..." to the recipient (works for both Instagram Login and Facebook Login accounts). The recipient must be signed in to Instagram to see it.
        - 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; `success` reports whether a typing indicator was actually sent to the platform (`false` on unsupported platforms or when the platform call failed).
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
        '404':
          description: Account or conversation not found

  /v1/inbox/conversations/{conversationId}/read:
    post:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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)
        - Instagram and Facebook Messenger: Any standard emoji, subject to Meta's 24h messaging window
        - Slack: The emoji must have a Slack name (e.g. `:thumbsup:`); unnamed characters return 400
        - 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:
      x-resource-group: "messages"
      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
        - Instagram and Facebook Messenger: Sends Meta's `unreact` action; the emoji does not need to be repeated
        - Slack: Removes the reaction we previously sent on that message
        - 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:
      x-resource-group: "publishing"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/accounts/{accountId}/instagram-ice-breakers:
    get:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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
                  minItems: 1
                  maxItems: 4
                  items:
                    type: object
                    required: [question, payload]
                    properties:
                      question: { type: string, minLength: 1, maxLength: 80 }
                      payload: { type: string, minLength: 1 }
      responses:
        '200':
          description: Ice breakers set successfully
        '400':
          description: Invalid request
        '401': { $ref: '#/components/responses/Unauthorized' }
    delete:
      x-resource-group: "accounts"
      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
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/accounts/{accountId}/telegram-commands:
    get:
      x-resource-group: "accounts"
      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, minLength: 1, maxLength: 32, pattern: '^[a-z0-9_]+$' }
                        description: { type: string, minLength: 1, maxLength: 256 }
        '400':
          description: Not a Telegram account
        '401': { $ref: '#/components/responses/Unauthorized' }
    put:
      x-resource-group: "accounts"
      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
                  maxItems: 100
                  items:
                    type: object
                    required: [command, description]
                    properties:
                      command: { type: string, minLength: 1, maxLength: 32, pattern: '^[a-z0-9_]+$', description: Bot command without leading slash }
                      description: { type: string, minLength: 1, maxLength: 256, description: Command description }
      responses:
        '200':
          description: Commands set successfully
        '400':
          description: Invalid request
        '401': { $ref: '#/components/responses/Unauthorized' }
    delete:
      x-resource-group: "accounts"
      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
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/inbox/conversations/{conversationId}/messages/{messageId}/attachments/{index}:
    get:
      x-resource-group: "messages"
      operationId: getMessageAttachment
      summary: Resolve message attachment
      description: |
        Resolve one attachment on a message to a media url that works right now.

        Instagram and Facebook sign DM media urls per request and expire them, so
        the `url` on a message is a snapshot: it works when you read the message
        and stops working later. This endpoint checks the stored url and, when it
        has gone stale, re-mints the message's media from Meta and persists it
        before answering. The message id never expires, so this URL is the one to
        store — it is returned on each attachment as `refreshUrl`.

        By default it responds `302` to the live media url, so it can be used
        directly as an `<img src>` on a browser session. API-key integrators
        should pass `?format=json` and read `url` off the body, since a browser
        cannot attach an Authorization header to an image request.

        Only Instagram and Facebook media can be re-minted. On other platforms
        the stored url is returned as-is when it still resolves, and `404`
        otherwise.
      tags: [Messages]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: conversationId
          in: path
          required: true
          schema: { type: string }
          description: 'The conversation ID (Zernio id or platform conversation id)'
        - name: messageId
          in: path
          required: true
          schema: { type: string }
          description: 'The message id as returned by the list-messages endpoint (the platform message id)'
        - name: index
          in: path
          required: true
          schema: { type: integer, minimum: 0 }
          description: 'Zero-based position of the attachment in the message''s attachments array'
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: Social account ID
        - name: format
          in: query
          required: false
          schema: { type: string, enum: [redirect, json], default: redirect }
          description: '`redirect` (default) answers 302 to the media; `json` returns the url in the body'
      responses:
        '200':
          description: 'Resolved url (only when format=json)'
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: success }
                  url: { type: string, description: 'Live media url. Short-lived; re-request this endpoint rather than storing it.' }
                  refreshed: { type: boolean, description: True when the stored url had expired and was re-minted from the platform. }
        '302':
          description: Redirect to the live media url
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
        '404':
          description: 'Account, conversation, message or attachment not found, or the platform no longer serves the media'

  /v1/inbox/comments:
    get:
      x-resource-group: "engagement"
      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.

        Pagination walks each account's platform listing. Following `nextCursor` reaches past
        the first page on Facebook and Instagram only, since they are the platforms that
        support a server-side date window; on the others the listing stops at its first page.
      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, description: "The post text/caption. On ad rows (isAd: true) this is the AD NAME, not the underlying post's caption — the creative text isn't exposed here." }
                        picture: { type: [string, "null"], description: 'Post media thumbnail. On ad rows this is the ad creative thumbnail.' }
                        permalink: { type: [string, "null"], description: 'Public URL of the post. On ad rows: the Facebook dark-post URL (facebook placement) or the IG media permalink (instagram placement); may be null when unknown.' }
                        createdTime: { type: string, format: date-time }
                        commentCount: { type: integer }
                        likeCount: { type: integer, description: 'Not fetched for ad rows (always 0 there).' }
                        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:
      x-resource-group: "engagement"
      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"], description: 'Only present when hasMore is true. Absent on the last page, so treat its absence as the end of the thread.' }
                  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, or the connected account is not permitted to read this post on the platform (code platform_api_error, type platform_error)'
        '429': { $ref: '#/components/responses/RateLimited' }
        '502':
          description: 'Upstream platform error (code platform_api_error, type platform_error)'
    post:
      x-resource-group: "engagement"
      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 }
                attachmentUrl:
                  type: string
                  format: uri
                  description: '(Facebook only) URL of an image to attach, publishing a photo comment alongside the text. The URL must be publicly accessible so Meta can fetch it. Returns 400 for other platforms.'
                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 }
        '400':
          description: 'Invalid request (e.g. attachmentUrl on a platform other than Facebook, code PLATFORM_NOT_SUPPORTED)'
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: 'Inbox addon required, or the connected account is not permitted to comment on this post on the platform (code platform_api_error, type platform_error)'
        '429': { $ref: '#/components/responses/RateLimited' }
        '502':
          description: 'Upstream platform error (code platform_api_error, type platform_error)'
    delete:
      x-resource-group: "engagement"
      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)'
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: 'Inbox addon required, or the connected account is not permitted to delete this comment on the platform (code platform_api_error, type platform_error)'
        '429': { $ref: '#/components/responses/RateLimited' }
        '502':
          description: 'Upstream platform error (code platform_api_error, type platform_error)'

  /v1/inbox/comments/{postId}/{commentId}:
    patch:
      x-resource-group: "engagement"
      operationId: editInboxComment
      summary: Edit comment
      description: |
        Edit the body of a comment the connected account posted. Supported on Reddit only.

        Reddit keeps the same comment id after an edit. Reddit exposes no API to edit a post
        title, and a link post has no editable body. To edit a published post's body, use
        `POST /v1/posts/{postId}/edit`.
      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, platform, content]
              properties:
                accountId: { type: string, description: The social account ID }
                platform: { type: string, enum: [reddit], description: Only Reddit supports editing a comment }
                content: { type: string, minLength: 1, description: The new comment body }
            example:
              accountId: "60f7a1b2c3d4e5f6a7b8c9d0"
              platform: "reddit"
              content: "Edited: corrected the version number."
      responses:
        '200':
          description: Comment edited
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  commentId: { type: string }
                  platform: { type: string }
              example:
                status: "success"
                commentId: "t1_xyz789"
                platform: "reddit"
        '400':
          description: "Platform does not support editing comments (code: platform_not_supported), or content missing."
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
        '404': { description: Account not found }
        '502': { description: Reddit was unreachable or returned an unclassified error. Reddit 4xx statuses are forwarded as-is. }

  /v1/inbox/comments/{postId}/{commentId}/moderation:
    post:
      x-resource-group: "engagement"
      operationId: setCommentModeration
      summary: Set comment moderation status
      description: |
        Set a comment's moderation status. Supported on YouTube only.

        Use this to work a moderation queue: approve a held comment (`published`), reject it
        (`rejected`), or send it back for review (`heldForReview`).

        The request must be authorized by the owner of the channel or video the comment
        belongs to. You cannot moderate comments on videos you do not own.

        This is distinct from `POST /v1/inbox/comments/{postId}/{commentId}/hide`, which
        covers Facebook, Instagram, Threads, and X/Twitter and does not apply to YouTube.
      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, platform, moderationStatus]
              properties:
                accountId: { type: string, description: The social account ID }
                platform: { type: string, enum: [youtube], description: Only YouTube supports comment moderation }
                moderationStatus:
                  type: string
                  enum: [published, rejected, heldForReview]
                  description: "published approves the comment, rejected removes it, heldForReview returns it to the queue."
                banAuthor:
                  type: boolean
                  description: >
                    Also ban the comment's author, auto-rejecting their future comments.
                    Only valid when moderationStatus is "rejected"; any other pairing is a 400.
            example:
              accountId: "60f7a1b2c3d4e5f6a7b8c9d0"
              platform: "youtube"
              moderationStatus: "rejected"
              banAuthor: true
      responses:
        '200':
          description: Moderation status applied
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
              example: { success: true }
        '400':
          description: "Platform does not support comment moderation (code: platform_not_supported), or banAuthor was set without moderationStatus=rejected."
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
        '404': { description: Account not found }
        '502': { description: "YouTube rejected the request (e.g. the account does not own the video)." }

  /v1/inbox/comments/{postId}/{commentId}/hide:
    post:
      x-resource-group: "engagement"
      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:
      x-resource-group: "engagement"
      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:
      x-resource-group: "engagement"
      operationId: likeInboxComment
      summary: Like comment
      description: |
        Like or upvote a comment on a post. Supported platforms: Facebook, Twitter/X,
        Bluesky, Reddit, LinkedIn. For Bluesky, the cid (content identifier) is required
        in the request body. For LinkedIn, pass the composite comment URN returned by the
        comments endpoints as commentId; an optional reactionType picks the reaction
        (defaults to LIKE), and accounts connected before the social-feed scopes were
        requested get a 403 with code `linkedin_reconnect_required`.
      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 }
                reactionType:
                  type: string
                  enum: [LIKE, PRAISE, EMPATHY, INTEREST, APPRECIATION, ENTERTAINMENT]
                  description: '(LinkedIn only) Reaction to create. Defaults to LIKE; ignored on other platforms.'
                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:
      x-resource-group: "engagement"
      operationId: unlikeInboxComment
      summary: Unlike comment
      description: |
        Remove a like from a comment. Supported platforms: Facebook, Twitter/X, Bluesky,
        Reddit, LinkedIn. 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/posts/{postId}/like:
    post:
      operationId: likePost
      summary: Like post
      description: |
        Like (or react to) a post as a connected account. Supported platforms: LinkedIn,
        Twitter/X, Facebook, YouTube, Bluesky. Instagram, Threads, TikTok and Pinterest
        expose no like endpoint in their APIs and return 400. Reddit returns 400 too,
        pointing at `POST /v1/accounts/{accountId}/reddit-vote`, which covers upvote,
        downvote and clear on both posts and comments.

        The account does not have to be the one that published the post, which is what
        makes executive engagement possible: pass an exec's `accountId` and the brand
        post's ID. `postId` accepts either a Zernio post ID or the platform's native post
        ID. A Zernio post ID resolves to the entry for `accountId`, falling back to the
        post's single entry on the same platform (two entries on that platform is a 400,
        so pass the native ID).

        LinkedIn requires the `w_member_social_feed` / `w_organization_social_feed`
        scopes, which are not retroactive: accounts connected before those were requested
        get a 403 with code `linkedin_reconnect_required` until the user reconnects the
        account. YouTube spends 50 quota units per call.
      tags: [Comments]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
          description: Zernio post ID or the platform's native post ID
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId: { type: string, description: The social account acting as the liker }
                reactionType:
                  type: string
                  enum: [LIKE, PRAISE, EMPATHY, INTEREST, APPRECIATION, ENTERTAINMENT]
                  description: '(LinkedIn only) Reaction to create. Defaults to LIKE; ignored on other platforms.'
                cid: { type: string, description: (Bluesky only) Content identifier of the post }
      responses:
        '200':
          description: Post liked
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  postId: { type: string, description: The resolved native post ID }
                  platform: { type: string }
                  liked: { type: boolean }
                  likeUri: { type: string, description: (Bluesky only) URI to use for unliking }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required, or the account is missing the platform scope
        '404':
          description: Account or post not found
    delete:
      operationId: unlikePost
      summary: Unlike post
      description: |
        Remove this account's like from a post. Supported platforms: LinkedIn, Twitter/X,
        Facebook, YouTube, Bluesky. On YouTube this clears the rating. For Bluesky,
        `likeUri` (returned when the post was liked) is required. Reddit uses
        `POST /v1/accounts/{accountId}/reddit-vote` with `direction: 0`.
      tags: [Comments]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: postId
          in: path
          required: true
          schema: { type: string }
          description: Zernio post ID or the platform's native post ID
        - 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: Post unliked
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string }
                  postId: { type: string, description: The resolved native post ID }
                  platform: { type: string }
                  liked: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required, or the account is missing the platform scope
        '404':
          description: Account or post not found

  /v1/inbox/comments/{postId}/{commentId}/private-reply:
    post:
      x-resource-group: "messages"
      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:
      x-resource-group: "engagement"
      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' }
        '403': { description: 'X rejected the request (e.g. suspended account, missing OAuth scope)' }
        '404': { description: Account not found }
    delete:
      x-resource-group: "engagement"
      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' }
        '403': { description: 'X rejected the request (e.g. suspended account, missing OAuth scope)' }
        '404': { description: Account not found }

  /v1/twitter/bookmark:
    post:
      x-resource-group: "engagement"
      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' }
        '403': { description: 'X rejected the request (e.g. suspended account, missing OAuth scope)' }
        '404': { description: Account not found }
    delete:
      x-resource-group: "engagement"
      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' }
        '403': { description: 'X rejected the request (e.g. suspended account, missing OAuth scope)' }
        '404': { description: Account not found }

  /v1/twitter/follow:
    post:
      x-resource-group: "engagement"
      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' }
        '403': { description: 'X rejected the request (e.g. suspended account, missing OAuth scope)' }
        '404': { description: Account not found }
    delete:
      x-resource-group: "engagement"
      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' }
        '403': { description: 'X rejected the request (e.g. suspended account, missing OAuth scope)' }
        '404': { description: Account not found }

  /v1/twitter/search:
    get:
      x-resource-group: "engagement"
      operationId: searchTweets
      summary: Search recent tweets
      description: |
        Search public tweets from the last 7 days matching an X search query, e.g. to discover tweets to reply to.
        The query string is passed through to X unchanged and supports X's search operators
        (`from:user`, `-is:retweet`, `is:reply`, `lang:en`, `"exact phrase"`, `conversation_id:123`, boolean `OR`, ...).
        Note that standalone operators like `is:` / `has:` / `lang:` must be combined with a keyword or `from:` clause.

        To reply to a found tweet, pass its `id` as the twitter platform entry's `platformSpecificData.replyToTweetId` when creating a post.

        Rate limit: 300 requests per 15-min window per connected account.
      tags: [Twitter Engagement]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: The social account ID
        - name: query
          in: query
          required: true
          schema: { type: string, minLength: 1, maxLength: 512 }
          description: 'X search query, max 512 characters. Operators are passed through unchanged; X rejects malformed queries with a 400.'
        - name: limit
          in: query
          schema: { type: integer, minimum: 10, maximum: 100, default: 10 }
          description: 'Results per page. X requires a minimum of 10; values below 10 are rejected.'
        - name: sinceId
          in: query
          schema: { type: string, pattern: '^[0-9]{1,19}$' }
          description: 'Only return tweets with an ID greater than (more recent than) this numeric tweet ID. Non-numeric values are rejected with 400.'
        - name: untilId
          in: query
          schema: { type: string, pattern: '^[0-9]{1,19}$' }
          description: 'Only return tweets with an ID less than (older than) this numeric tweet ID. Non-numeric values are rejected with 400.'
        - name: startTime
          in: query
          schema: { type: string, format: date-time }
          description: 'Oldest UTC timestamp (ISO 8601, inclusive), within the last 7 days'
        - name: endTime
          in: query
          schema: { type: string, format: date-time }
          description: 'Newest UTC timestamp (ISO 8601, exclusive), within the last 7 days'
        - name: cursor
          in: query
          schema: { type: string }
          description: Pagination cursor from a previous response
        - name: sortOrder
          in: query
          schema: { type: string, enum: [recency, relevancy], default: recency }
      responses:
        '200':
          description: Matching tweets
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: success }
                  tweets:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        text: { type: string }
                        created: { type: string, format: date-time }
                        conversationId: { type: string }
                        inReplyToTweetId: { type: [string, "null"], description: 'Parent tweet ID when the result is itself a reply' }
                        lang: { type: string }
                        author:
                          type: object
                          properties:
                            id: { type: string }
                            username: { type: string }
                            displayName: { type: string }
                            avatar: { type: string }
                            verifiedType: { type: string }
                        likeCount: { type: integer }
                        replyCount: { type: integer }
                        retweetCount: { type: integer }
                        quoteCount: { type: integer }
                        platform: { type: string, example: twitter }
                  pagination:
                    type: object
                    properties:
                      hasMore: { type: boolean }
                      cursor: { type: [string, "null"] }
                  meta:
                    type: object
                    properties:
                      resultCount: { type: integer }
                      newestId: { type: [string, "null"] }
                      oldestId: { type: [string, "null"] }
                      platform: { type: string, example: twitter }
        '400': { description: 'Bad request (invalid params, or X rejected the query as malformed)' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { description: 'X API spend cap reached for this billing period' }
        '403': { description: 'X analytics capability not enabled for this account (code X_ANALYTICS_NOT_ENABLED)' }
        '404': { description: Account not found }
        '429': { description: 'X search rate limit exceeded (300 requests per 15 minutes)' }

  /v1/twitter/tweet:
    get:
      x-resource-group: "engagement"
      operationId: getTweet
      summary: Look up a tweet
      description: |
        Resolve a single tweet by ID or URL into its text, author and public metrics.

        Use this to render a post you are referencing, e.g. the tweet quoted by a quote-style post.
        Unlike `/v1/twitter/search` this is not limited to the last 7 days and works for any tweet
        visible to the connected account.

        Billed as an X posts read ($0.005). Repeat lookups of the same tweet within the same UTC day
        are charged once.
      tags: [Twitter Engagement]
      security: [{ bearerAuth: [] }]
      parameters:
        - name: accountId
          in: query
          required: true
          schema: { type: string }
          description: The social account ID whose X token is used for the lookup
        - name: id
          in: query
          required: true
          schema: { type: string }
          description: 'Numeric tweet ID or a tweet URL (e.g. https://x.com/user/status/123...)'
      responses:
        '200':
          description: The resolved tweet
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, example: success }
                  tweet:
                    type: object
                    properties:
                      id: { type: string }
                      text: { type: string }
                      created: { type: string, format: date-time }
                      conversationId: { type: string }
                      inReplyToTweetId: { type: [string, "null"], description: 'Parent tweet ID when the tweet is itself a reply' }
                      lang: { type: string }
                      author:
                        type: object
                        properties:
                          id: { type: string }
                          username: { type: string }
                          displayName: { type: string }
                          avatar: { type: string }
                          verifiedType: { type: string }
                      likeCount: { type: integer }
                      replyCount: { type: integer }
                      retweetCount: { type: integer }
                      quoteCount: { type: integer }
                      platform: { type: string, example: twitter }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '402': { description: 'X API spend cap reached for this billing period' }
        '403': { description: 'X analytics capability not enabled for this account (code X_ANALYTICS_NOT_ENABLED), or the tweet author is protected or suspended' }
        '404': { description: 'Account not found, or the tweet was deleted or never existed' }
        '429': { description: 'X rate limit exceeded' }

  /v1/inbox/mentions:
    get:
      x-resource-group: "engagement"
      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
                        authorName:
                          type: [string, "null"]
                          description: Display name of the author, resolved from authorUrn. Null when LinkedIn does not allow resolving the profile.
                        authorUsername:
                          type: [string, "null"]
                          description: LinkedIn vanity name of the author (the slug in their profile URL)
                        authorPicture:
                          type: [string, "null"]
                          description: Profile picture URL of the author. LinkedIn CDN URLs expire after some time, so fetch promptly rather than storing long-term.
                        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/mentions/reply:
    post:
      x-resource-group: "engagement"
      operationId: replyToMention
      summary: Reply to a mention
      description: |
        Reply to a mention of the connected account. Supported on Instagram only.

        Two shapes, selected by whether `commentId` is present:

        - **Comment mention** (someone @mentioned the account inside a comment): pass both
          `mediaId` and `commentId`. Instagram posts a reply under that comment.
        - **Caption mention** (someone @mentioned the account in their media caption, so no
          comment exists): pass `mediaId` only. Instagram posts a comment on their media.

        Story mentions are not supported by Instagram's API.

        Note that `GET /v1/inbox/mentions` currently returns LinkedIn mentions only and does
        not surface Instagram mentions. Source `mediaId` and `commentId` from Instagram's
        `comments` webhook, which is where mention notifications are delivered for accounts
        connected through Instagram Login.
      tags: [Mentions]
      security: [{ bearerAuth: [] }]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, mediaId, message]
              properties:
                accountId: { type: string, description: The Instagram social account ID }
                mediaId: { type: string, description: The ID of the media the account was mentioned in }
                commentId:
                  type: string
                  description: The mentioning comment's ID. Omit for a caption mention.
                message: { type: string, description: The reply text }
            example:
              accountId: "60f7a1b2c3d4e5f6a7b8c9d0"
              mediaId: "17895695668004550"
              commentId: "17870913561140000"
              message: "Thanks for the shout-out!"
      responses:
        '200':
          description: Reply posted
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  id: { type: string, description: ID of the created reply or comment }
              example:
                success: true
                id: "17912345678901234"
        '400':
          description: "Platform does not support replying to mentions (code: platform_not_supported), or missing mediaId/message."
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403':
          description: Inbox addon required
        '404': { description: Account not found }
        '502': { description: Instagram was unreachable or returned an unclassified error. Instagram 4xx statuses are forwarded as-is. }

  /v1/inbox/reviews:
    get:
      x-resource-group: "engagement"
      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, description: 'Review identifier. For Google Business this is the full review resource name (accounts/{accountId}/locations/{locationId}/reviews/{reviewId}), so it also encodes the location.' }
                        platform: { type: string }
                        accountId: { type: string }
                        accountUsername: { type: string }
                        locationId: { type: string, description: 'Bare GBP location id the review belongs to. Google Business only; absent for other platforms.' }
                        locationName: { type: [string, "null"], description: 'Human-readable GBP location display name. Google Business only; absent for other platforms.' }
                        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 }
                        hasPhotos:
                          type: boolean
                          description: Whether the review has at least one photo. Google Business only; always false for other platforms.
                        photoCount:
                          type: integer
                          description: Number of photos attached to the review (photos only; videos are not counted). Google Business only; 0 for other platforms.
                        photos:
                          type: array
                          description: 'Photos attached to the review. Google Business only; always an empty array for other platforms.'
                          items:
                            type: object
                            properties:
                              url: { type: string, format: uri }
                        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:
      x-resource-group: "engagement"
      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:
      x-resource-group: "engagement"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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)
                parameter_format:
                  type: string
                  enum: [POSITIONAL, NAMED, positional, named]
                  description: 'Variable style: POSITIONAL ({{1}}, the default) or NAMED ({{customer_name}}). Named templates provide examples via body_text_named_params / header_text_named_params. Inferred as NAMED when omitted but a named-params example is present.'
                components:
                  type: array
                  description: "Template components (header, body, footer, buttons, carousel, limited_time_offer). 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:
      x-resource-group: "accounts"
      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' }
        '502': { description: Meta rejected the request or was unreachable. Meta 4xx statuses are forwarded as-is. }
    patch:
      x-resource-group: "accounts"
      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' }
        '502': { description: Meta rejected the update or was unreachable. Meta 4xx statuses are forwarded as-is. }
    delete:
      x-resource-group: "accounts"
      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' }
        '502': { description: Meta rejected the delete or was unreachable. Meta 4xx statuses are forwarded as-is. }

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

  /v1/whatsapp/calling:
    get:
      x-resource-group: "accounts"
      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 }
                  callerIdMode:
                    type: string
                    enum: [business, platform]
                    description: 'Caller ID the forward-leg callee sees on tel: forwards. business = this WhatsApp number; platform = a Zernio number (customer-brought number without verified caller ID; verify via /v1/phone-numbers/{id}/whatsapp/caller-id-verification).'
                  callerIdVerified: { type: boolean, description: 'True once the number completed caller-ID verification.' }
                  maxCallDurationSeconds: { type: [integer, "null"], description: 'Hard cap (seconds) on forwarded calls; null = no cap.' }
                  forwardCallerId: { type: string, enum: [business, caller] }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp phone number not found for this account }

  /v1/whatsapp/phone-numbers/{id}/calling:
    post:
      x-resource-group: "telephony"
      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 }
                maxCallDurationSeconds: { type: integer, minimum: 30, maximum: 14400, description: 'Hard cap (seconds) on a forwarded call; the carrier hangs up both legs when it fires. Safety valve against dead-air billing when a destination hangs up but the signal is lost.' }
                forwardCallerId: { type: string, enum: [business, caller], default: business, description: 'Caller ID presented to the forward destination. caller = the WhatsApp user''s number (sip: destinations only; ignored on tel: forwards). Fixes AI-agent trunks that reject seeing the business number call itself.' }
      responses:
        '200':
          description: Calling enabled
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  callingEnabled: { type: boolean }
                  sipHostname: { type: string }
                  forwardTo: { type: string }
                  callerIdMode:
                    type: string
                    enum: [business, platform]
                    description: 'Caller ID the forward-leg callee sees on tel: forwards. business = this WhatsApp number; platform = a Zernio number (customer-brought number without verified caller ID).'
        '400': { description: 'Invalid request (including forwardTo set to the number itself)' }
        '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:
      x-resource-group: "telephony"
      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 }
                maxCallDurationSeconds: { type: [integer, "null"], minimum: 30, maximum: 14400, description: 'Hard cap (seconds) on forwarded calls; null clears the cap.' }
                forwardCallerId: { type: string, enum: [business, caller], description: 'caller = present the WhatsApp user''s number to the forward destination (sip: only).' }
      responses:
        '200': { description: Updated }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp phone number not found }
        '422': { description: Calling must be enabled before settings can be updated }
    delete:
      x-resource-group: "telephony"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp phone number not found }

  /v1/whatsapp/call-permissions:
    get:
      x-resource-group: "contacts"
      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:
      x-resource-group: "messages"
      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:
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      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:
      x-resource-group: "messages"
      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/{id}:
    get:
      x-resource-group: "messages"
      operationId: getWhatsAppCall
      tags: [WhatsApp Calling]
      summary: Get a single call
      security:
        - bearerAuth: []
      parameters:
        - { name: id, 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/{id}/recording:
    get:
      x-resource-group: "messages"
      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: id, 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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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." }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Not enrolled in the calling beta }
        '404': { description: Call not found }

  /v1/calls/{id}/recording:
    get:
      x-resource-group: "messages"
      operationId: getCallRecording
      tags: [Calls]
      summary: Get a call recording
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '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:
      x-resource-group: "messages"
      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:
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      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.
                sendAt:
                  type: string
                  format: date-time
                  description: "Optional. Schedule the send for a future time (ISO 8601 with offset, e.g. `2026-08-01T12:00:00Z`). Must be in the future. The message is queued and the `message.delivered` webhook fires when it actually sends."
      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': { $ref: '#/components/responses/IdempotencyKeyInFlight' }
        '422': { $ref: '#/components/responses/IdempotencyKeyReused' }
        '502': { description: Carrier-side send failed }

  /v1/sms/lookup:
    get:
      x-resource-group: "telephony"
      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:
      x-resource-group: "contacts"
      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/sender-ids:
    post:
      x-resource-group: "telephony"
      operationId: createSmsSenderId
      tags: [SMS]
      summary: Create an alphanumeric sender ID
      description: |
        Registers an alphanumeric sender ID (e.g. `ZERNIO`) — a branded `from`
        for one-way international SMS. No phone number purchase or carrier
        registration is needed; once created, pass it as `from` on
        `POST /v1/sms/messages`.

        Constraints: 3-11 characters (letters, digits, spaces; at least one
        letter). Sends cannot reach the US, Canada, or Puerto Rico, are
        text-only, and recipients cannot reply. Sender IDs that impersonate
        well-known brands or institutions are rejected. Names are not
        exclusive: the same sender ID can be registered by any number of
        workspaces. Creating the same sender ID again is a no-op
        (re-activates it after a delete).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [senderId]
              properties:
                senderId:
                  type: string
                  minLength: 3
                  maxLength: 11
                  description: 'The sender ID recipients will see (3-11 letters/digits/spaces, at least one letter, no leading/trailing space).'
      responses:
        '200':
          description: Sender ID created (or re-activated).
          content:
            application/json:
              schema:
                type: object
                properties:
                  id: { type: string, description: Sender ID resource id. }
                  senderId: { type: string }
                  isActive: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: 'Workspace is not on usage-based billing, or already holds the maximum of 1,000 active sender IDs (code `sender_id_limit_reached`; raisable via support).' }
        '422': { description: 'Sender ID rejected: it appears to impersonate a protected brand or institution.' }
    get:
      x-resource-group: "telephony"
      operationId: listSmsSenderIds
      tags: [SMS]
      summary: List alphanumeric sender IDs
      security:
        - bearerAuth: []
      responses:
        '200':
          description: The workspace's sender IDs, newest first.
          content:
            application/json:
              schema:
                type: object
                properties:
                  senderIds:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        senderId: { type: string }
                        isActive: { type: boolean }
                        createdAt: { type: [string, "null"], format: date-time }
                  budget:
                    type: object
                    description: 'Workspace-wide daily sending budget, shared by every sender ID (resets midnight UTC).'
                    properties:
                      cap: { type: integer, description: 'Daily message cap (raisable via `/v1/sms/sender-ids/limit-request`).' }
                      usedToday: { type: integer, description: Messages already counted against today's cap. }
                      level: { type: integer, description: 'Cap tier (Level 1 = 500/day).' }
                      pendingRequest:
                        type: [object, "null"]
                        description: 'The in-flight cap-raise request awaiting review, or null. While set, further requests return 409.'
                        properties:
                          requestedCap: { type: integer }
                          level: { type: integer }
                          requestedAt: { type: [string, "null"], format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/sms/sender-ids/limit-request:
    post:
      x-resource-group: "telephony"
      operationId: requestSmsSenderIdLimitIncrease
      tags: [SMS]
      summary: Request a higher sender ID daily limit
      description: |
        Asks support to raise the workspace's daily sender-ID message cap.
        There is no self-serve raise: the request (desired cap + use case) is
        reviewed manually, usually within a business day.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [requestedCap, reason]
              properties:
                requestedCap: { type: integer, description: 'Desired daily message cap. Must exceed the current cap.' }
                reason: { type: string, minLength: 10, maxLength: 2000, description: 'Use case and audience (what you send, to whom, opt-in status).' }
      responses:
        '200':
          description: Request submitted for review.
          content:
            application/json:
              schema:
                type: object
                properties:
                  requested: { type: boolean }
                  requestedCap: { type: integer }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { description: 'A cap-raise request is already awaiting review (code `sender_id_raise_pending`); one at a time.' }
        '503': { description: Request could not be submitted; retry or contact support. }

  /v1/sms/sender-ids/{id}:
    delete:
      x-resource-group: "telephony"
      operationId: deleteSmsSenderId
      tags: [SMS]
      summary: Delete an alphanumeric sender ID
      description: |
        Deactivates the sender ID so it can no longer send. Re-creating the
        same sender ID via `POST /v1/sms/sender-ids` re-activates it.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: Sender ID resource id. }
      responses:
        '200':
          description: Sender ID deactivated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  deleted: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Sender ID not found. }

  /v1/sms/registrations:
    post:
      x-resource-group: "telephony"
      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]
              properties:
                registrationType: { type: string, enum: [standard_10dlc, sole_prop_10dlc, toll_free] }
                phoneNumbers:
                  type: array
                  items: { type: string }
                  description: 'Your numbers this registration covers. When omitted or empty on a 10DLC registration, defaults to your active SMS-enabled US local numbers not already covered by another registration.'
                brand:
                  type: object
                  description: Required for 10DLC. The legal entity behind the traffic (TCR brand).
                  required: [entityType, displayName, country, vertical, website, street, city, state, postalCode]
                  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, description: Business contact phone. Required for every entityType except SOLE_PROPRIETOR. }
                    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, minLength: 2, maxLength: 2, description: "ISO 3166-1 alpha-2 country where the company is registered. Companies worldwide can register standard 10DLC (non-US companies use their local tax ID in `ein`; carrier vetting may take longer). SOLE_PROPRIETOR is US/CA only." }
                    email: { type: string, format: email, description: Brand contact email; defaults to your account email when omitted. }
                    website: { type: string, description: "The brand's website (sole proprietors may use a social profile such as LinkedIn or a business Facebook page). Carriers verify the brand against it; a bare domain is normalized to https://." }
                    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.
                    The opt-in/opt-out/help auto-responses (`optinMessage`,
                    `optoutMessage`, `helpMessage`) are optional: when omitted, a
                    compliant, brand-named template with the carrier-required
                    disclosures is generated for you. If you do send them, they must
                    name the registered brand and carry the disclosures — submissions
                    that don't are rewritten to the compliant template before the
                    campaign is filed.
                  required: [usecase, description, messageFlow, sample1, sample2, optinKeywords, optoutKeywords, helpKeywords]
                  properties:
                    usecase: { type: string }
                    subUsecases:
                      type: array
                      minItems: 2
                      maxItems: 5
                      items: { type: string, enum: [2FA, ACCOUNT_NOTIFICATION, CUSTOMER_CARE, DELIVERY_NOTIFICATION, FRAUD_ALERT, HIGHER_EDUCATION, MARKETING, POLLING_VOTING, PUBLIC_SERVICE_ANNOUNCEMENT, SECURITY_ALERT] }
                      description: |
                        The concrete kinds of messages a MIXED campaign sends (the carrier
                        registry requires 2-5, and reviewers match them against the sample
                        messages). Omitted: a default pair is applied for MIXED.
                    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). Include a link to the page or form where they opt in — carrier reviewers reject campaigns whose consent they can't verify." }
                    sample1: { type: string, minLength: 20 }
                    sample2: { type: string, minLength: 20, description: "Second example message; carriers require two distinct samples, so it must differ from sample1." }
                    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, description: "Whether messages carry links. Auto-derived from the samples when omitted, so the declaration matches what the reviewer reads." }
                    embeddedPhone: { type: boolean, description: "Whether messages carry phone numbers. Auto-derived from the samples when omitted." }
                    numberPool: { type: boolean }
                    ageGated: { type: boolean }
                    directLending: { type: boolean }
                    privacyPolicyLink: { type: string, description: "Link to your privacy policy. Recommended: reviewers check that it says mobile information is not sold or shared with third parties for promotional purposes. A bare domain is normalized to https://." }
                    termsAndConditionsLink: { type: string, description: "Link to your terms & conditions. A bare domain is normalized to https://." }
                messagingBrandName:
                  type: string
                  minLength: 2
                  maxLength: 60
                  description: 'DBA / trade name used to brand message content (samples and auto-replies) when it differs from the legal name, e.g. a sole proprietor texting under a business name. The legal `brand.displayName` is still what the carrier vets.'
                wizardValues:
                  type: object
                  additionalProperties: { type: string }
                  description: Raw dashboard-wizard answers, stored only to prefill edit-and-resubmit. API integrators can omit.
                resubmitRequestId: { type: string, description: "Resubmit a registration that was returned for changes — updates it in place instead of creating a new one." }
                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." }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { description: Carrier registry rejected a field; `param` names it when known. }
    get:
      x-resource-group: "telephony"
      operationId: listSmsRegistrations
      tags: [SMS]
      summary: List carrier registrations
      security:
        - bearerAuth: []
      parameters:
        - name: includeDeactivated
          in: query
          required: false
          schema: { type: boolean }
          description: Deactivated (terminated) registrations are hidden by default — pass true to include them.
      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, requested, changes_requested, deactivated], description: "requested/changes_requested = pre-submission review states; customers see them as pending / needs changes." }
                        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"] }
                        tfActionRequiredAt: { type: [string, "null"], format: date-time, description: 'Toll-free only: when the carrier requested changes ("Waiting For Customer"). The request must be resubmitted within 7 days of this timestamp or it expires.' }
                        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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/sms/registrations/preflight:
    post:
      x-resource-group: "telephony"
      operationId: preflightSmsRegistration
      tags: [SMS]
      summary: Pre-check a carrier registration
      description: |
        Dry-run of `POST /v1/sms/registrations` for 10DLC: validates and
        composes the exact brand/campaign payloads a submission would store
        (branding, disclosures, auto-replies), runs deterministic compliance
        lints plus an AI reviewer over them, and returns the findings WITHOUT
        creating anything. Use it to fix issues before submitting; `block`
        severity findings indicate a near-certain carrier rejection.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [registrationType, brand, campaign]
              properties:
                registrationType: { type: string, enum: [standard_10dlc, sole_prop_10dlc] }
                phoneNumbers: { type: array, items: { type: string } }
                brand: { type: object, description: 'Same shape as the registration `brand`.' }
                campaign: { type: object, description: 'Same shape as the registration `campaign`.' }
                messagingBrandName: { type: string, minLength: 2, maxLength: 60 }
      responses:
        '200':
          description: Composed payloads + findings.
          content:
            application/json:
              schema:
                type: object
                properties:
                  composed:
                    type: object
                    description: 'The exact payloads a submission would store (post-branding, disclosures appended, auto-replies generated).'
                    properties:
                      brand: { type: object }
                      campaign: { type: object }
                  advisories:
                    type: array
                    items:
                      type: object
                      properties:
                        field: { type: [string, "null"], description: 'The payload field the finding is about, when attributable.' }
                        code: { type: [string, "null"], description: 'Stable rule id for deterministic findings; absent on AI findings.' }
                        concern: { type: string }
                        severity: { type: string, enum: [block, warn] }
                  verdict: { type: string, enum: [pass, warn, fail, unreviewed] }
                  aiUnavailable: { type: boolean, description: 'True when the AI portion of the check could not run; advisories then contain only deterministic findings.' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
  /v1/sms/registrations/{id}:
    delete:
      x-resource-group: "telephony"
      operationId: deactivateSmsRegistration
      tags: [SMS]
      summary: Deactivate a brand/campaign registration
      description: |
        Terminates the campaign with the carrier registry so the recurring
        monthly campaign fee stops (carriers bill the first 3 months of a
        campaign regardless). Numbers covered by it can no longer SEND texts —
        receiving is unaffected — until they're registered under a new brand.
        Irreversible: a deactivated campaign cannot be restored; texting again
        later requires a new registration (new one-time and review fees).
        Idempotent.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Registration deactivated.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [deactivated] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Registration not found }
    get:
      x-resource-group: "telephony"
      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, requested, changes_requested, deactivated], description: "requested/changes_requested = pre-submission review states; customers see them as pending / needs changes." }
                  brandStatus: { type: string }
                  campaignStatus: { type: string }
                  declineReason: { type: [string, "null"] }
                  phoneNumbers: { type: array, items: { type: string } }
                  awaitingOtp: { type: boolean }
                  campaignContent:
                    type: object
                    description: |
                      The submitted campaign content, present only for rejected
                      registrations with a campaign. Edit and resubmit it via the
                      appeal endpoint's optional content fields.
                    properties:
                      messageFlow: { type: string }
                      sample1: { type: string }
                      sample2: { type: string }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Registration not found }

  /v1/sms/registrations/{id}/verify-otp:
    post:
      x-resource-group: "telephony"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Registration not found }

  /v1/sms/registrations/{id}/resend-otp:
    post:
      x-resource-group: "telephony"
      operationId: resendSmsRegistrationOtp
      tags: [SMS]
      summary: Re-send the sole-prop OTP
      description: |
        Re-sends the sole-proprietor verification PIN to the brand's mobile
        number — use it when the original code expired or never arrived. Only
        valid while the registration is pending and awaiting its OTP; rate
        limited to one send per minute.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: A new code was sent
          content:
            application/json:
              schema:
                type: object
                properties:
                  sent: { type: boolean }
        '400': { description: 'Malformed `id`, or the registration is not awaiting a verification code.' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Registration not found }
        '429': { description: A code was just sent — wait a minute before requesting another }

  /v1/sms/registrations/{id}/appeal:
    post:
      x-resource-group: "telephony"
      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`.

        Content rejections (e.g. an opt-in flow without a verifiable form link,
        or unrealistic samples) should be FIXED in the same call: pass the
        corrected `messageFlow` / `sample1` / `sample2` and the campaign is
        updated before the appeal is filed, so the reviewer sees the new
        content. The current content is on `GET /v1/sms/registrations/{id}`
        (`campaignContent`).
      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, description: Goes verbatim to the carrier reviewer — address the decline reason directly. }
                messageFlow: { type: string, minLength: 40, maxLength: 2048, description: Corrected opt-in flow; include a link to the opt-in page/form. }
                sample1: { type: string, minLength: 20 }
                sample2: { type: string, minLength: 20 }
      responses:
        '200':
          description: Appeal submitted; the registration is pending again.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [pending] }
        '400': { description: 'Malformed `id`, or the 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/{id}/respond:
    post:
      x-resource-group: "telephony"
      operationId: respondToSmsRegistrationReview
      tags: [SMS]
      summary: Reply to a change request
      description: |
        Replies to a reviewer change request on a registration in
        `changes_requested` state: a note, hosted document URLs (from
        `POST /v1/sms/opt-in-proof`), or both, sent together. The registration
        returns to `requested` (back in review) — no need to resubmit the
        whole registration. To change the submitted brand/campaign fields
        themselves, resubmit via `POST /v1/sms/registrations` with
        `resubmitRequestId` instead.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                note: { type: string, maxLength: 2000, description: 'Answer for the reviewer. Required when no files are sent.' }
                files:
                  type: array
                  maxItems: 5
                  items: { type: string }
                  description: 'Hosted document URLs returned by POST /v1/sms/opt-in-proof.'
      responses:
        '200':
          description: Reply recorded; the registration is back in review.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [requested] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Registration not found }
        '409': { description: Registration is not waiting on changes }

  /v1/sms/opt-in-proof:
    post:
      x-resource-group: "contacts"
      operationId: uploadSmsOptInProofFile
      tags: [SMS]
      summary: Upload opt-in form proof
      description: |
        Hosts a screenshot (or PDF) of your SMS opt-in form and returns its
        public URL. Include that URL in the campaign's `messageFlow` (the
        opt-in workflow text) — the carrier registry has no attachment field,
        so reviewers verify consent by opening links in that answer. Works
        before a registration exists (use it when registering) and for
        appeals. `/v1/sms/registrations/{id}/opt-in-proof` is an alias.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary, description: "PNG, JPG, WebP, GIF or PDF, max 4MB." }
      responses:
        '200':
          description: File hosted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, description: Public URL to reference in the opt-in flow text. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { description: Unsupported file type or file too large }

  /v1/sms/registrations/{id}/opt-in-proof:
    post:
      x-resource-group: "contacts"
      operationId: uploadSmsOptInProof
      tags: [SMS]
      summary: Upload opt-in form proof for an appeal
      description: |
        Hosts a screenshot (or PDF) of your SMS opt-in form and returns its
        public URL. Carrier reviewers reject campaigns whose consent can't be
        verified and ask for a "link/screenshot of the opt-in form" — the
        registry has no attachment field, so include the returned URL inside
        the `messageFlow` you submit with the appeal
        (`POST /v1/sms/registrations/{id}/appeal`).
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          multipart/form-data:
            schema:
              type: object
              required: [file]
              properties:
                file: { type: string, format: binary, description: "PNG, JPG, WebP, GIF or PDF, max 4MB." }
      responses:
        '200':
          description: File hosted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  url: { type: string, description: Public URL to reference in the opt-in flow text. }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Registration not found }
        '422': { description: Unsupported file type or file too large }

  /v1/sms/registrations/share:
    post:
      x-resource-group: "telephony"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Number not found }

  /v1/whatsapp/template-library:
    get:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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.

        Provide the image either as a binary upload (`multipart/form-data` with `file`)
        or as a download URL (`application/json` with `url`) — with a URL we fetch the
        image server-side and upload the bytes for you. Meta's profile-photo API is
        bytes-only, so there is no direct URL passthrough. JPEG/PNG, max 5MB either way.
      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)
          application/json:
            schema:
              type: object
              required: [accountId, url]
              properties:
                accountId:
                  type: string
                  description: WhatsApp social account ID
                url:
                  type: string
                  format: uri
                  description: Publicly reachable https URL of the image (JPEG or PNG, max 5MB, recommended 640x640). Fetched server-side; must resolve directly without redirects.
      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/URL, file too large, or missing parameters" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found }
        '422': { description: "Profile photo is locked for WhatsApp coexistence numbers (manage it in the WhatsApp Business app)" }

  /v1/whatsapp/business-profile/display-name:
    get:
      x-resource-group: "accounts"
      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 }
        '400': { description: accountId is required }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: WhatsApp account not found or accountId is not a valid ObjectId }
    post:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "contacts"
      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:
      x-resource-group: "contacts"
      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:
      x-resource-group: "contacts"
      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:
      x-resource-group: "contacts"
      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:
      x-resource-group: "contacts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      operationId: purchasePhoneNumber
      tags: [Phone Numbers]
      summary: Purchase phone number
      description: |
        Payment-first: you do not pick a specific number, the system provisions one and
        auto-assigns it. With usage-based billing active and a payment method on file, the
        number provisions inline and bills per month on your usage-based invoice (there is
        no checkout redirect). No payment method on file returns `402 PAYMENT_REQUIRED`;
        a regulated country returns `202` with `status: "kyc_required"` and a `kycUrl`.

        Requires usage-based billing (the Usage 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: >
                    Preferred profile for the number. One number = one
                    profile, so when the requested profile already holds a
                    number the API assigns the next free profile instead (or
                    creates one) and returns the actual assignment in
                    `profileId` on the response.
                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
                numberType:
                  type: string
                  enum: [local, mobile, national, toll_free]
                  description: >
                    Which of the country's offered number types to order
                    (see `types[]` on GET /v1/phone-numbers/countries). Omitted
                    = the country's default type, which is always the
                    WhatsApp-safe choice. Capabilities, price, and KYC
                    requirements are per (country, type): toll_free can never
                    connect WhatsApp (400 when combined with
                    connectWhatsapp:true), and wantsSms:true requires an
                    SMS-capable type.
                areaCode:
                  type: string
                  pattern: '^\d{1,4}$'
                  description: >
                    Area code (national destination code, e.g. 11 for Sao
                    Paulo) the number must be in. Hard constraint: when the
                    area has no deliverable inventory the purchase fails with
                    409 code AREA_CODE_UNAVAILABLE instead of assigning a
                    number from another area, and later replacements stay in
                    this area too. Omit for any area. Get live options from
                    GET /v1/phone-numbers/availability (areaOptions).
                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).
                wantsWhatsapp:
                  type: boolean
                  default: false
                  description: >
                    Declare WhatsApp intent on a STANDALONE purchase
                    (connectWhatsapp:false). The number still activates and
                    bills immediately, but if WhatsApp's buy-time check rejects
                    the assigned number, it is automatically swapped for a
                    WhatsApp-eligible one during the purchase instead of being
                    delivered with WhatsApp unavailable. Ignored on the WhatsApp
                    provisioning path (connectWhatsapp omitted or true), which
                    always delivers a WhatsApp-verified number.
                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, profileId } 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 }
                          profileId: { type: string, description: 'The profile the number was actually assigned to.' }
                  - 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 }
                      profileId: { type: string, description: 'The profile the number was actually assigned to.' }
        '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: >
            Either duplicate-purchase protection (code PURCHASE_VELOCITY:
            another number was purchased within the last 10 minutes; retry
            with allowMultiple: true to confirm), or the requested areaCode
            has no deliverable inventory right now (code
            AREA_CODE_UNAVAILABLE: pick another area or omit areaCode).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, enum: [PURCHASE_VELOCITY, AREA_CODE_UNAVAILABLE] }
        '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 }
                  numberType: { type: string, description: The type that will be ordered after KYC approval. }
                  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:
      x-resource-group: "telephony"
      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`)." }
                        inStock: { type: boolean, description: "Live carrier-stock snapshot (refreshed every 6h + on availability checks): false when NO offered type currently has deliverable inventory, so a purchase would fail. Treat as advisory; the purchase itself re-checks." }
                        types:
                          type: array
                          description: >
                            Every number type offered in this country (default
                            first). Capabilities, KYC tier, monthly price, and
                            stock are per type. The country-level fields above
                            mirror the first (default) entry. Pass the chosen
                            `numberType` to POST /v1/phone-numbers/purchase.
                          items:
                            type: object
                            properties:
                              numberType: { type: string, enum: [local, mobile, national, toll_free] }
                              tier: { type: integer, enum: [1, 2, 3, 4] }
                              needsKyc: { type: boolean }
                              monthlyCents: { type: integer }
                              whatsappAvailable: { type: boolean, description: "Always false for toll_free (WhatsApp does not reliably register toll-free numbers)." }
                              smsAvailable: { type: boolean }
                              callsAvailable: { type: boolean }
                              inStock: { type: boolean }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/phone-numbers/available:
    get:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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. }
        - { name: numberType, in: query, required: false, schema: { type: string, enum: [local, mobile, national, toll_free] }, description: "Check a specific offered type (stock and address constraints are per type). Omitted = the country's default type." }
        - { name: sms, in: query, required: false, schema: { type: boolean }, description: 'Pass true when the buyer wants SMS: availability, areas, and areaOptions then describe the SMS-capable pool (an SMS purchase orders from it), not the wider voice-only pool.' }
      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 }
                  areaOptions:
                    type: array
                    description: >
                      Live inventory grouped by area code. For US and CA this
                      is the full country inventory (every area code with
                      stock, recognizable metros listed first, then
                      alphabetical); other countries are ordered largest
                      stock first;
                      they list the areas in the latest inventory page
                      (up to 500 numbers, which for most countries is the
                      entire pool). Empty when out of stock (or the area
                      lookup failed). Pass a chosen `ndc` as `areaCode` on POST
                      /v1/phone-numbers/purchase (or on the KYC submit for
                      regulated countries) to require that area.
                    items:
                      type: object
                      properties:
                        ndc: { type: string, description: 'Area code (national destination code), e.g. "11".' }
                        name: { type: string, description: 'Area name: "City, ST" for US/CA (e.g. "Miami, FL"), city otherwise (e.g. "Sao Paulo").' }
                        count: { type: integer, description: 'Numbers available in this area: country-wide count for US/CA, numbers seen on the latest inventory page otherwise.' }
        '400': { description: 'Country not offerable, or the inventory provider rejected the lookup (its 4xx status is forwarded as-is).' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '502': { description: 'The inventory provider was unreachable or returned an unclassified error.' }

  /v1/whatsapp/phone-numbers:
    get:
      x-resource-group: "telephony"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/phone-numbers/purchase:
    post:
      x-resource-group: "telephony"
      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.

        Payment-first: you do not pick a specific number, the system provisions one and
        auto-assigns it. With usage-based billing active and a payment method on file, the
        number provisions inline and bills per month on your usage-based invoice (there is
        no checkout redirect). No payment method on file returns `402 PAYMENT_REQUIRED`;
        a regulated country returns `202` with `status: "kyc_required"` and a `kycUrl`.

        Requires usage-based billing (the Usage 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: >
            Either duplicate-purchase protection (code PURCHASE_VELOCITY:
            another number was purchased within the last 10 minutes; retry
            with allowMultiple: true to confirm), or the requested areaCode
            has no deliverable inventory right now (code
            AREA_CODE_UNAVAILABLE: pick another area or omit areaCode).
          content:
            application/json:
              schema:
                type: object
                properties:
                  error: { type: string }
                  code: { type: string, enum: [PURCHASE_VELOCITY, AREA_CODE_UNAVAILABLE] }
        '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 }
                  numberType: { type: string, description: The type that will be ordered after KYC approval. }
                  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:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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. }
        - { name: numberType, in: query, required: false, schema: { type: string, enum: [local, mobile, national, toll_free] }, description: "Check a specific offered type (stock and address constraints are per type). Omitted = the country's default type." }
        - { name: sms, in: query, required: false, schema: { type: boolean }, description: 'Pass true when the buyer wants SMS: availability, areas, and areaOptions then describe the SMS-capable pool (an SMS purchase orders from it), not the wider voice-only pool.' }
      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 }
                  areaOptions:
                    type: array
                    description: >
                      Live inventory grouped by area code. For US and CA this
                      is the full country inventory (every area code with
                      stock, recognizable metros listed first, then
                      alphabetical); other countries are ordered largest
                      stock first;
                      they list the areas in the latest inventory page
                      (up to 500 numbers, which for most countries is the
                      entire pool). Empty when out of stock (or the area
                      lookup failed). Pass a chosen `ndc` as `areaCode` on POST
                      /v1/phone-numbers/purchase (or on the KYC submit for
                      regulated countries) to require that area.
                    items:
                      type: object
                      properties:
                        ndc: { type: string, description: 'Area code (national destination code), e.g. "11".' }
                        name: { type: string, description: 'Area name: "City, ST" for US/CA (e.g. "Miami, FL"), city otherwise (e.g. "Sao Paulo").' }
                        count: { type: integer, description: 'Numbers available in this area: country-wide count for US/CA, numbers seen on the latest inventory page otherwise.' }
        '400': { description: 'Country not offerable, or the inventory provider rejected the lookup (its 4xx status is forwarded as-is).' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '502': { description: 'The inventory provider was unreachable or returned an unclassified error.' }

  /v1/phone-numbers/kyc:
    get:
      x-resource-group: "telephony"
      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 } }
        - { name: numberType, in: query, required: false, schema: { type: string, enum: [local, mobile, national, toll_free] }, description: "Requirements and reuse eligibility are per (country, type). Omitted = the country's default type. Pass the same value on the POST." }
      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 a reusable verification for the country (skip the form). `fromPhoneNumber`/`details` mirror the first option; `options` lists ALL reusable verifications (agencies hold one per end client), approved-first. Pass the chosen option''s `id` as `reuseOptionId` on POST. Each option''s `instant` says whether it activates in minutes (group-approved) or still queues for carrier review (1-3 days).'
                    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:
                            id: { type: string, description: 'Opaque option id — pass as `reuseOptionId` on POST. Stable selection key (a phone number is not unique across verifications).' }
                            fromPhoneNumber: { type: string, description: Display only — the number this verification was submitted for. Not a selection key. }
                            instant: { type: boolean, description: 'true = group-approved, a new order activates in minutes; false = documents are reused but the order still queues for carrier review (1-3 days).' }
                            details:
                              type: array
                              items:
                                type: object
                                properties:
                                  label: { type: string }
                                  value: { type: string }
                                  documentId: { type: string, description: 'Present on document rows — the Telnyx document id. GET /v1/whatsapp/phone-numbers/kyc/document/{documentId} streams it (auth-scoped, inline PDF).' }
        '400': { description: Country not available }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      x-resource-group: "telephony"
      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). With `areaCode`, a quantity above that area''s live stock is rejected with a 400.' }
                reuse: { type: boolean, description: Reuse a prior approved verification for this country (skips document/field collection; places the order immediately). }
                reuseOptionId: { type: string, description: 'Which reusable verification to use (GET reusable.options[].id). The unambiguous selection key. Omitted = the approved default. No match = 409.' }
                reuseFrom: { type: string, description: 'Legacy fallback for `reuseOptionId`: the source phone number (GET reusable.options[].fromPhoneNumber). Ambiguous when a number labels two verifications — prefer `reuseOptionId`. Omitted = the approved default. No match = 409.' }
                areaCode: { type: string, pattern: '^\d{1,4}$', description: 'Area code (NDC) the number must be in. Hard constraint: an empty area pool fails with 409 code AREA_CODE_UNAVAILABLE instead of ordering from another area. Omit for any area. Options come from GET /v1/phone-numbers/availability (areaOptions); the purchase 202 kycUrl echoes the areaCode picked at purchase time so it can be passed here.' }
                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: 'Either reuse was requested but no prior approved verification exists for this country, or the requested areaCode has no deliverable inventory right now (code: area_code_unavailable; pick another area and resubmit).' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/phone-numbers/kyc/document/{documentId}:
    get:
      x-resource-group: "telephony"
      operationId: viewPhoneNumberKycDocument
      tags: [Phone Numbers]
      summary: View a KYC document on file
      description: |
        Stream a document backing a reusable verification (the `documentId`
        values from GET /v1/phone-numbers/kyc `reusable.options[].details[]`), so
        the account holder can see what's on file before reusing it. Returned
        inline as `application/pdf` (uploads are normalized to PDF). Auth-scoped:
        a document is viewable only when its id is referenced by one of the
        caller's own numbers — otherwise `404`.
      parameters:
        - name: documentId
          in: path
          required: true
          schema: { type: string }
          description: The Telnyx document id (from `reusable.options[].details[].documentId`).
      responses:
        '200':
          description: The document, streamed inline.
          content:
            application/pdf:
              schema: { type: string, format: binary }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: No such document for this account. }

  /v1/phone-numbers/kyc/upload-document:
    post:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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). }
                areaCode: { type: string, pattern: '^\d{1,4}$', description: 'Area code (NDC) the eventual number must be in. Hard constraint carried by the link; the end customer filling the form makes no area choice. Options come from GET /v1/phone-numbers/availability (areaOptions).' }
                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:
      x-resource-group: "telephony"
      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 — uploaded documents must be attached to an order within 30
        minutes or the carrier deletes them, so upload right before this call.
        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).

        Non-US/CA numbers additionally need the country-specific values from
        GET /v1/phone-numbers/port-in/requirements, passed via `requirements`,
        and must be submitted one country per request. When required
        information is still missing after submission, the order is kept as a
        resumable draft whose `error` / `declineReason` names the gaps.
      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. The
                    losing carrier matches every field against its records and rejects the
                    whole port on a mismatch — enter values exactly as they appear on the
                    carrier bill.
                  required: [entityName, authPersonName, accountNumber, streetAddress, locality, postalCode, countryCode]
                  properties:
                    entityName: { type: string, description: "Account holder / business name, as on the carrier account." }
                    authPersonName: { type: string, description: "Full name (first + last) of the person authorizing the port — must match the LOA signature." }
                    billingPhoneNumber: { type: string, description: "Phone number on the losing carrier's bill. Defaults to the ported number itself on single-number orders. Validated as a real phone number when present." }
                    accountNumber: { type: string, description: "Account number with the losing carrier — required (carriers reject ports without it; on prepaid mobile plans it is often the phone number itself)." }
                    pinPasscode: { type: string, description: "Transfer PIN. Required for US/CA mobile numbers (wireless carriers reject PIN-less ports). Forwarded to the carrier, never stored. International porting codes (e.g. the UK PAC) go through `requirements` instead." }
                    taxIdentifier: { type: string, maxLength: 50, description: "Company tax id on the carrier account (EU ports, e.g. Spanish CIF)." }
                    businessIdentifier: { type: string, maxLength: 50, description: "Business registration id on the carrier account (EU ports)." }
                    streetAddress: { type: string }
                    extendedAddress: { type: string }
                    locality: { type: string }
                    administrativeArea: { type: string, description: "Region. Required for US/CA as the 2-letter state/province code (full names are accepted and normalized); optional elsewhere." }
                    postalCode: { type: string, description: "Postal code. Validated as a US ZIP / Canadian postal code for US/CA; free-form elsewhere." }
                    countryCode: { type: string, minLength: 2, maxLength: 2, enum: [US, CA, GB, ES, DE, FR, NL, AU, BR], description: "Service-address country (a supported port-in country)." }
                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. US/CA default is one week out (shifted off weekends); international orders are scheduled into the carrier's next allowed porting window at or after this date." }
                customerReference: { type: string, maxLength: 100 }
                portType: { type: string, enum: [full, partial], default: full, description: "Whether the losing account ports all its numbers (full) or keeps some (partial)." }
                requirements:
                  type: array
                  maxItems: 30
                  description: >-
                    Country-specific requirement values for international ports
                    (from GET /v1/phone-numbers/port-in/requirements). Not
                    needed for US/CA. The LOA and invoice requirements are
                    satisfied automatically by loaDocumentId/invoiceDocumentId,
                    and address-type requirements by the endUser service
                    address.
                  items:
                    type: object
                    required: [requirementTypeId, fieldValue]
                    properties:
                      requirementTypeId: { type: string, description: "The requirement's id, from the requirements endpoint." }
                      fieldValue: { type: string, maxLength: 1000, description: "Text value, ISO datetime, or a documentId from POST /v1/phone-numbers/port-in/documents, per the requirement's kind." }
      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). }
        '400': { $ref: '#/components/responses/BadRequest' }
        '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), numbers span multiple non-US/CA countries, or every split order failed to submit" }
    get:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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. }
                        lineType: { type: [string, "null"], description: "Line type when known (mobile, landline, voip…). A US/CA mobile number requires the transfer PIN at submit." }
                        countryCode: { type: [string, "null"], description: "ISO country of the number — pass it to GET /v1/phone-numbers/port-in/requirements for international numbers." }
                        phoneNumberType: { type: [string, "null"], description: "Carrier number-type classification (local, mobile, national, toll_free…) — the numberType for the requirements endpoint." }
                        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:
      x-resource-group: "telephony"
      operationId: uploadPhoneNumberPortInDocument
      tags: [Phone Numbers]
      summary: Upload a porting document
      description: |
        Upload ONE porting document and get back its `documentId`. For the
        signed LOA / carrier invoice the id goes to `loaDocumentId` /
        `invoiceDocumentId`; for a country-specific document requirement
        (international ports) it becomes that requirement's `fieldValue`.
        Requirement documents are normalized to PDF automatically (regulators
        reject raw images). PDF, JPEG, or PNG, 10MB max. Uploads must be
        attached to an order within 30 minutes or the carrier deletes them.
      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, description: "'loa', 'invoice', or any short slug for requirement documents. 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/requirements:
    get:
      x-resource-group: "telephony"
      operationId: getPhoneNumberPortInRequirements
      tags: [Phone Numbers]
      summary: Country porting requirements
      description: |
        The country-specific information a port-in needs BEYOND the LOA,
        invoice, and account/address details — e.g. an ID copy, proof of
        address, a tax id, or a porting code. Call it after the portability
        check (which returns each number's `countryCode` and
        `phoneNumberType`), render the fields, and pass the collected values as
        the create request's `requirements`. US/CA return an empty list.
      security:
        - bearerAuth: []
      parameters:
        - { name: country, in: query, required: true, schema: { type: string, minLength: 2, maxLength: 2 }, description: "ISO country of the numbers being ported (a supported port-in country)." }
        - { name: numberType, in: query, required: false, schema: { type: string, enum: [local, mobile, national, toll_free], default: local }, description: "The portability check's phoneNumberType — requirements differ by type." }
      responses:
        '200':
          description: Requirement fields for the country/type combination.
          content:
            application/json:
              schema:
                type: object
                properties:
                  country: { type: string }
                  numberType: { type: string }
                  supported: { type: boolean, description: "false when the combination includes a step that can't be completed through the API (e.g. an in-person identity verification) — porting it needs support." }
                  fields:
                    type: array
                    items:
                      type: object
                      properties:
                        requirementId: { type: string, description: "Pass back as requirements[].requirementTypeId." }
                        label: { type: string }
                        kind: { type: string, enum: [text, date, address, file, action], description: "text/date take a string value; file takes a documentId from the documents endpoint; address is satisfied automatically from the end-user service address." }
                        description: { type: string }
                        example: { type: string }
                        acceptableValues: { type: array, items: { type: string }, description: "When present, the value must be one of these." }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { description: "Country not supported for port-in" }

  /v1/phone-numbers/port-in/{id}/requirements:
    get:
      x-resource-group: "telephony"
      operationId: getPhoneNumberPortInOrderRequirements
      tags: [Phone Numbers]
      summary: A port-in order's pending requirements
      description: |
        The live requirements on an EXISTING porting order: which are filled,
        which are still pending, and which bounced on review
        (`requirement-info-exception`). Use it to fix and resubmit a rejected
        international port. Same field shape as the country-level requirements
        endpoint, plus per-requirement status.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: Porting order ID (from the port-in list). }
      responses:
        '200':
          description: The order's requirements with statuses.
          content:
            application/json:
              schema:
                type: object
                properties:
                  country: { type: string }
                  requirements:
                    type: array
                    items:
                      type: object
                      properties:
                        requirementId: { type: string }
                        label: { type: string }
                        kind: { type: string, enum: [text, date, address, file, action] }
                        description: { type: string }
                        example: { type: string }
                        acceptableValues: { type: array, items: { type: string } }
                        status: { type: string, description: "requirement-info-pending | requirement-info-under-review | requirement-info-exception | approved" }
                        filled: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Porting order not found }

  /v1/phone-numbers/port-in/{id}:
    delete:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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/phone-numbers/{id}/remediate/reply:
    post:
      x-resource-group: "telephony"
      operationId: replyToPhoneNumberReviewer
      tags: [Phone Numbers]
      summary: Reply to the regulatory reviewer
      description: |
        Post a free-text reply (with optional file attachments) to the reviewer
        on a number awaiting remediation — for asks the structured form can't
        express (e.g. "is this personal or business?"). Attachments are stored by
        us and their links are added to the reviewer's comment thread (the
        carrier's number order takes no loose files). A reply to a comment-style
        ask moves the number back to "in review"; a reply on a formal decline is
        supplementary and you must still resubmit the fix. Requires text or at
        least one attachment.
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                text: { type: string, maxLength: 2000, description: The reply message to the reviewer. }
                attachments:
                  type: array
                  maxItems: 5
                  description: 'Files (PDF/JPG/PNG/WEBP, max 10 MB each) whose links are added to the reply.'
                  items:
                    type: object
                    required: [filename, base64]
                    properties:
                      filename: { type: string }
                      base64: { type: string, description: Base64-encoded file bytes. }
      responses:
        '200':
          description: Reply posted.
          content:
            application/json:
              schema:
                type: object
                properties:
                  posted: { type: boolean }
                  attachments: { type: integer, description: Number of attachments uploaded. }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Number not found }
        '502': { description: Couldn't deliver the reply to the reviewer; retry. }

  /v1/phone-numbers/{id}/remediate/respond:
    post:
      x-resource-group: "telephony"
      operationId: respondToPhoneNumberReviewer
      tags: [Phone Numbers]
      summary: Respond to the regulatory reviewer (message + corrections)
      description: |
        Send a single response to the reviewer on a number awaiting remediation:
        a free-text message and/or corrected requirement documents, in one call.
        If corrections are present they are PATCHed onto the requirement group and
        re-submitted (the number goes back to "in review"); if a message or file
        attachments are present they are posted to the reviewer's comment thread.
        When both are present, your message is the thread comment and the resubmit
        drives the state change. At least one of message, corrections, or
        attachments is required. `documents` correct requirement slots; `attachments`
        are loose files (their links are added to your message).
      parameters:
        - name: id
          in: path
          required: true
          schema: { type: string }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              properties:
                message: { type: string, maxLength: 2000, description: Your message to the reviewer. }
                documents:
                  type: array
                  description: 'Corrected requirement documents, each keyed to its requirement.'
                  items:
                    type: object
                    required: [requirementId]
                    properties:
                      requirementId: { type: string }
                      filename: { type: string }
                      base64: { type: string, description: Base64-encoded file bytes (or supply documentId instead). }
                      documentId: { type: string, description: Id of a document already uploaded out-of-band. }
                address:
                  type: object
                  description: A corrected address record, keyed to its requirement.
                entityType:
                  type: [string, 'null']
                  enum: [individual, business, null]
                attachments:
                  type: array
                  maxItems: 5
                  description: 'Loose files (PDF/JPG/PNG/WEBP, max 10 MB each) whose links are added to your message.'
                  items:
                    type: object
                    required: [filename, base64]
                    properties:
                      filename: { type: string }
                      base64: { type: string, description: Base64-encoded file bytes. }
      responses:
        '200':
          description: Response sent.
          content:
            application/json:
              schema:
                type: object
                properties:
                  status: { type: string, enum: [resubmitted, replied], description: '`resubmitted` when corrections were submitted, `replied` when it was message-only.' }
                  posted: { type: boolean, description: Whether a message/attachments were posted to the reviewer. }
                  phoneNumber:
                    type: object
                    properties:
                      id: { type: string }
                      status: { type: string }
                  siblingsResubmitted: { type: integer, description: Other numbers on the same registration the correction fanned out to. }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Number not found }
        '409': { description: Number's registration is held under our own carrier registration; nothing for you to correct. }
        '502': { description: Couldn't deliver your response to the reviewer; retry. }

  /v1/whatsapp/phone-numbers/kyc:
    get:
      x-resource-group: "telephony"
      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 a reusable verification for the country (skip the form). `fromPhoneNumber`/`details` mirror the first option; `options` lists ALL reusable verifications (agencies hold one per end client), approved-first. Pass the chosen option''s `id` as `reuseOptionId` on POST. Each option''s `instant` says whether it activates in minutes (group-approved) or still queues for carrier review (1-3 days).'
                    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:
                            id: { type: string, description: 'Opaque option id — pass as `reuseOptionId` on POST. Stable selection key (a phone number is not unique across verifications).' }
                            fromPhoneNumber: { type: string, description: Display only — the number this verification was submitted for. Not a selection key. }
                            instant: { type: boolean, description: 'true = group-approved, a new order activates in minutes; false = documents are reused but the order still queues for carrier review (1-3 days).' }
                            details:
                              type: array
                              items:
                                type: object
                                properties:
                                  label: { type: string }
                                  value: { type: string }
                                  documentId: { type: string, description: 'Present on document rows — the Telnyx document id. GET /v1/whatsapp/phone-numbers/kyc/document/{documentId} streams it (auth-scoped, inline PDF).' }
        '400': { description: Country not available }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      x-resource-group: "telephony"
      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). With `areaCode`, a quantity above that area''s live stock is rejected with a 400.' }
                reuse: { type: boolean, description: Reuse a prior approved verification for this country (skips document/field collection; places the order immediately). }
                reuseOptionId: { type: string, description: 'Which reusable verification to use (GET reusable.options[].id). The unambiguous selection key. Omitted = the approved default. No match = 409.' }
                reuseFrom: { type: string, description: 'Legacy fallback for `reuseOptionId`: the source phone number (GET reusable.options[].fromPhoneNumber). Ambiguous when a number labels two verifications — prefer `reuseOptionId`. Omitted = the approved default. No match = 409.' }
                areaCode: { type: string, pattern: '^\d{1,4}$', description: 'Area code (NDC) the number must be in. Hard constraint: an empty area pool fails with 409 code AREA_CODE_UNAVAILABLE instead of ordering from another area. Omit for any area. Options come from GET /v1/phone-numbers/availability (areaOptions); the purchase 202 kycUrl echoes the areaCode picked at purchase time so it can be passed here.' }
                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: 'Either reuse was requested but no prior approved verification exists for this country, or the requested areaCode has no deliverable inventory right now (code: area_code_unavailable; pick another area and resubmit).' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/phone-numbers/kyc/upload-document:
    post:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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). }
                areaCode: { type: string, pattern: '^\d{1,4}$', description: 'Area code (NDC) the eventual number must be in. Hard constraint carried by the link; the end customer filling the form makes no area choice. Options come from GET /v1/phone-numbers/availability (areaOptions).' }
                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}/profile:
    patch:
      x-resource-group: "telephony"
      operationId: moveWhatsAppNumberToProfile
      tags: [WhatsApp Phone Numbers]
      summary: Move a number to another profile
      description: |
        Move a provisioned number to a different profile.

        A number is not a single record. Alongside the number itself there are
        hidden telephony owner accounts (platform `phone`, plus `sms` when SMS is
        enabled) and, once WhatsApp is connected, the `whatsapp` account. They all
        carry a profileId and this endpoint moves them together.

        Use this instead of `PATCH /v1/accounts/{accountId}`: that one moves the
        social account only and leaves the number itself pinned to its original
        profile, which splits the number across two profiles. Connecting a
        Zernio-provisioned number from any profile but its own is rejected with a
        `409` (`WHATSAPP_NUMBER_PINNED_TO_PROFILE`). This endpoint is how you
        re-home the number first, so it can then be connected from the new profile.

        `id` is the number record id from `GET /v1/phone-numbers`, not an account id.

        A profile holds at most one account per platform, so the destination must be
        free of every platform this number occupies.
      security:
        - bearerAuth: []
      parameters:
        - { name: id, in: path, required: true, schema: { type: string }, description: WhatsAppPhoneNumber id. }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId]
              properties:
                profileId: { type: string, description: Destination profile id. Must belong to the same team. }
      responses:
        '200':
          description: Number moved, or already on that profile.
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
                  profileId: { type: string, description: The profile the number is now on. }
                  movedPlatforms:
                    type: array
                    items: { type: string }
                    description: 'Platforms whose accounts travelled with the number (phone, sms, whatsapp). Absent when the number was already on the destination profile.'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: 'No access to the source or destination profile, or the Inbox add-on is not active.' }
        '404': { description: 'Number not found, or the destination profile does not exist.' }
        '409': { description: 'The destination profile already holds an account on one of the platforms this number occupies.' }

  /v1/whatsapp/phone-numbers/{id}/remediate:
    get:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      operationId: reuseSmsRegistrationForNumber
      tags: [SMS]
      summary: Add number to SMS 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, requested, changes_requested, deactivated], description: "requested/changes_requested = pre-submission review states; customers see them as pending / needs changes." }
        '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:
      x-resource-group: "telephony"
      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." }
                  callerIdMode:
                    type: string
                    enum: [business, platform]
                    description: 'Caller ID the forward-leg callee sees on tel: forwards. business = this WhatsApp number; platform = a Zernio number (used when the number was brought by the customer and its caller ID is not verified for PSTN origination).'
                  callerIdVerified: { type: boolean, description: 'True once the number completed caller-ID verification, making tel: forwards display the business number itself.' }
                  maxCallDurationSeconds: { type: [integer, "null"], description: 'Hard cap (seconds) on forwarded calls; null = no cap.' }
                  forwardCallerId: { type: string, enum: [business, caller] }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Number not found }
    post:
      x-resource-group: "telephony"
      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 }
                maxCallDurationSeconds: { type: integer, minimum: 30, maximum: 14400, description: 'Hard cap (seconds) on a forwarded call; the carrier hangs up both legs when it fires. Safety valve against dead-air billing when a destination hangs up but the signal is lost.' }
                forwardCallerId: { type: string, enum: [business, caller], default: business, description: 'Caller ID presented to the forward destination. caller = the WhatsApp user''s number (sip: destinations only; ignored on tel: forwards). Fixes AI-agent trunks that reject seeing the business number call itself.' }
      responses:
        '200':
          description: Calling enabled
          content:
            application/json:
              schema:
                type: object
                properties:
                  success: { type: boolean }
                  callingEnabled: { type: boolean }
                  sipHostname: { type: string }
                  forwardTo: { type: string }
                  callerIdMode:
                    type: string
                    enum: [business, platform]
                    description: 'Caller ID the forward-leg callee sees on tel: forwards. business = this WhatsApp number; platform = a Zernio number (customer-brought number without verified caller ID).'
        '400': { description: 'Invalid request (including forwardTo set to the number itself)' }
        '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:
      x-resource-group: "telephony"
      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 }
                maxCallDurationSeconds: { type: [integer, "null"], minimum: 30, maximum: 14400, description: 'Hard cap (seconds) on forwarded calls; null clears the cap.' }
                forwardCallerId: { type: string, enum: [business, caller], description: 'caller = present the WhatsApp user''s number to the forward destination (sip: only).' }
      responses:
        '200': { description: Updated }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Phone number not found }
        '422': { description: Calling must be enabled before settings can be updated }
    delete:
      x-resource-group: "telephony"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Phone number not found }

  /v1/phone-numbers/{id}/whatsapp/caller-id-verification:
    post:
      x-resource-group: "telephony"
      operationId: startWhatsAppCallerIdVerification
      tags: [WhatsApp Calling]
      summary: Start caller-ID verification for a customer-brought number
      description: |
        Customer-brought (BYO) WhatsApp numbers cannot present themselves as
        caller ID on `tel:` call forwards until verified (carrier
        anti-spoofing); until then forwarded calls show a Zernio number
        (`callerIdMode: platform` on the calling config). This sends a
        one-time code to the number by SMS or voice call. Re-POST to resend.
        Zernio-purchased numbers never need this and get a 400.
      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:
                method: { type: string, enum: [sms, call], default: sms }
      responses:
        '200':
          description: 'Code sent (or the number was already verified)'
          content:
            application/json:
              schema:
                type: object
                properties:
                  verified: { type: boolean }
                  codeSent: { type: boolean }
                  method: { type: string, enum: [sms, call] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Number not found }
        '429': { description: 'Too many verification attempts for this number; wait before retrying' }

  /v1/phone-numbers/{id}/whatsapp/caller-id-verification/verify:
    post:
      x-resource-group: "telephony"
      operationId: verifyWhatsAppCallerId
      tags: [WhatsApp Calling]
      summary: Confirm the caller-ID verification code
      description: |
        Submits the one-time code the number received. On success, `tel:`
        call forwards present the business number itself as caller ID
        (`callerIdMode: business`).
      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: [code]
              properties:
                code: { type: string, minLength: 4, maxLength: 10 }
      responses:
        '200':
          description: Verified
          content:
            application/json:
              schema:
                type: object
                properties:
                  verified: { type: boolean }
        '400': { description: 'Invalid or expired code, or malformed request' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Number not found }
        '429': { description: 'Attempt lockout from the carrier; wait a few minutes, then request a fresh code' }

  /v1/whatsapp/phone-numbers/{phoneNumberId}:
    get:
      x-resource-group: "telephony"
      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:
      x-resource-group: "telephony"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      x-resource-group: "messages"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/wa-groups/{groupId}:
    get:
      x-resource-group: "messages"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      x-resource-group: "messages"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      x-resource-group: "messages"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/whatsapp/wa-groups/{groupId}/participants:
    post:
      x-resource-group: "messages"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    delete:
      x-resource-group: "messages"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/wa-groups/{groupId}/invite-link:
    post:
      x-resource-group: "messages"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/whatsapp/wa-groups/{groupId}/join-requests:
    get:
      x-resource-group: "messages"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      x-resource-group: "messages"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    delete:
      x-resource-group: "messages"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

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

  /v1/whatsapp/flows:
    get:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "contacts"
      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. Matches the profile recorded on the contact itself, which is set when the contact is created and is independent of the profile its account currently belongs to. Filter by accountId to list a contact through its channel instead.' }
        - { name: accountId, in: query, schema: { type: string }, description: 'Filter by the SocialAccount that owns the contact channel. Contacts are resolved through their channels, so the profileId contact filter is not applied while accountId is set. A profileId sent alongside is still access-checked and still scopes the returned filters.tags list.' }
        - { name: search, in: query, schema: { type: string } }
        - { name: tag, in: query, schema: { type: string } }
        - { name: tags, in: query, schema: { type: string }, description: 'Comma-separated tags, matches contacts carrying any of them' }
        - { 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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      x-resource-group: "contacts"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { description: 'Duplicate channel. The platformIdentifier is already bound to a channel on this accountId.' }

  /v1/contacts/{contactId}:
    get:
      x-resource-group: "contacts"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      x-resource-group: "contacts"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      x-resource-group: "contacts"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/contacts/{contactId}/channels:
    get:
      x-resource-group: "contacts"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/contacts/bulk:
    post:
      x-resource-group: "contacts"
      operationId: bulkCreateContacts
      summary: Bulk create contacts
      description: 'Import up to 1000 contacts at a time. Skips duplicates, merging any new tags onto the existing contact. accountId is required whenever contacts carry a platformIdentifier (or a row-level accountId); platform is always derived from the resolved account, never used to decide whether channels are created, and a mismatched platform 404s as account not found. When accountId is set, each contact must carry a platformIdentifier; a row missing it is rejected individually (reported in errors[], HTTP 200), not a 400 for the whole import. On phone platforms (whatsapp, sms) the platformIdentifier is normalized to digits and a value that is not phone-shaped is rejected per contact and reported in errors[], not imported.'
      tags: [Contacts]
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [profileId, contacts]
              properties:
                profileId: { type: string }
                accountId: { type: string, description: 'Required when contacts carry channel data (platformIdentifier or a row-level accountId). Omit for a plain CRM import with no channels.' }
                platform: { type: string, description: 'Ignored when accountId is set: the platform is derived from the resolved account. Only relevant to disambiguate accountId lookup; a mismatch 404s.' }
                contacts:
                  type: array
                  maxItems: 1000
                  items:
                    type: object
                    required: [name]
                    properties:
                      name: { type: string }
                      platformIdentifier: { type: string, description: 'Required when the top-level accountId is set (channel mode). A row missing it in that mode is rejected individually and reported in errors[], not a 400 for the whole import.' }
                      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: string }, description: 'Per-contact failures, e.g. an identifier that is not a valid phone number' }
                  total: { type: integer }
        '400': { description: 'Contact missing required field name, or a row carries platformIdentifier/accountId with no top-level accountId to attach it to. A row missing platformIdentifier while accountId IS set is not a 400: it is reported in errors[] under a 200.' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/contacts/{contactId}/fields/{slug}:
    put:
      x-resource-group: "contacts"
      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:
      x-resource-group: "contacts"
      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:
      x-resource-group: "accounts"
      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 }
        '400': { description: Invalid profileId format }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      x-resource-group: "accounts"
      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 }
        '400': { description: Invalid request body }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '409': { description: Duplicate slug }

  /v1/custom-fields/{fieldId}:
    patch:
      x-resource-group: "accounts"
      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:
        required: true
        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 } }
        '400': { description: Invalid fieldId or request body }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      x-resource-group: "accounts"
      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 }
        '400': { description: Invalid fieldId format }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

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

  /v1/broadcasts:
    get:
      x-resource-group: "messages"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      x-resource-group: "messages"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/broadcasts/{broadcastId}:
    get:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      x-resource-group: "messages"
      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 }, maxItems: 20000, 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) }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

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

  /v1/workflows:
    get:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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:
      x-resource-group: "accounts"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/workflows/{workflowId}/executions:
    get:
      x-resource-group: "messages"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    post:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/workflows/{workflowId}/duplicate:
    post:
      x-resource-group: "accounts"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/workflows/{workflowId}/versions:
    get:
      x-resource-group: "accounts"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/workflows/{workflowId}/versions/{version}:
    get:
      x-resource-group: "accounts"
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/workflows/{workflowId}/versions/{version}/restore:
    post:
      x-resource-group: "accounts"
      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:
      x-resource-group: "messages"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      x-resource-group: "messages"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/sequences/{sequenceId}:
    get:
      x-resource-group: "messages"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      x-resource-group: "messages"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      x-resource-group: "messages"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sequences/{sequenceId}/activate:
    post:
      x-resource-group: "messages"
      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 request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sequences/{sequenceId}/pause:
    post:
      x-resource-group: "messages"
      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: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sequences/{sequenceId}/enroll:
    post:
      x-resource-group: "messages"
      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 }
                  failed: { type: integer, description: 'Number that failed (already enrolled, or no subscribed channel on the sequence platform)' }
                  results:
                    type: array
                    description: Per-contact outcome
                    items:
                      type: object
                      properties:
                        contactId: { type: string }
                        success: { type: boolean }
                        error: { type: string, description: Present when success is false }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sequences/{sequenceId}/enroll/{contactId}:
    delete:
      x-resource-group: "messages"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/sequences/{sequenceId}/enrollments:
    get:
      x-resource-group: "messages"
      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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

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

  /v1/comment-automations:
    get:
      x-resource-group: "messages"
      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, word], description: "How a keyword is compared with the comment. 'contains' (default) matches anywhere, even inside another word (keyword 'app' fires on 'happy'). 'word' matches the keyword only as a standalone word. 'exact' requires the whole comment to be exactly the keyword." }
                        excludeKeywords: { type: array, items: { type: string }, description: "Comments containing one of these never trigger the automation, even when a trigger keyword also matches. Compared using the same matchMode." }
                        typoTolerance: { type: boolean, description: "Only with matchMode=word: also fire on close misspellings of a keyword (one edit for 4-7 character keywords, two from 8 up). Keywords shorter than 4 characters are never fuzzy-matched." }
                        dmMessage: { type: string }
                        buttons:
                          type: array
                          items: { $ref: '#/components/schemas/DmButton' }
                          description: Inline DM buttons (up to 3). Omitted when none are set.
                        template:
                          $ref: '#/components/schemas/CommentAutomationTemplate'
                        commentReply: { type: string }
                        dmMessageVariations: { type: array, items: { type: string }, description: "Alternate DM texts rotated at random with dmMessage. Omitted when none." }
                        commentReplyVariations: { type: array, items: { type: string }, description: "Alternate public replies rotated at random with commentReply. Omitted when none." }
                        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." }
                        dmDelaySeconds: { type: integer, description: "Seconds waited after the trigger before the DM is sent. Absent when the DM goes out immediately." }
                        commentReplyDelaySeconds: { type: integer, description: "Seconds waited before the public reply is posted. Absent when it follows the DM immediately." }
                        alsoMatchInDms: { type: boolean, description: "Whether these keywords also fire on a plain inbound DM." }
                        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 }
        '400': { description: Invalid request }
        '401': { $ref: '#/components/responses/Unauthorized' }
    post:
      x-resource-group: "messages"
      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.

        Audience (`audience`, Instagram only): restrict the automation to followers or
        non-followers, and/or to accounts above a follower count. Instagram only reveals the
        follow relationship for people who have messaged the account, so `audience.whenUnknown`
        decides what happens for everyone else - including `verify`, which sends a one-tap
        confirmation DM (`followGate`) and then delivers the real DM automatically. People we
        already know follow you skip the tap entirely.

        Set `alsoMatchInDms: true` on a `comment` automation to also answer people who send
        a keyword as a direct message instead of commenting it. One automation then covers
        both doors, and each door is deduplicated separately (someone who already got the DM
        from their comment still gets it if they later DM the keyword). Requires at least one
        keyword.

        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, word], default: contains, description: "How a keyword is compared with the comment. 'contains' (default) matches anywhere, even inside another word (keyword 'app' fires on 'happy'). 'word' matches the keyword only as a standalone word. 'exact' requires the whole comment to be exactly the keyword." }
                excludeKeywords: { type: array, items: { type: string }, description: "Comments containing one of these never trigger the automation, even when a trigger keyword also matches. Compared using the same matchMode." }
                typoTolerance: { type: boolean, description: "Only with matchMode=word: also fire on close misspellings of a keyword (one edit for 4-7 character keywords, two from 8 up). Keywords shorter than 4 characters are never fuzzy-matched." }
                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."
                template:
                  allOf: [{ $ref: '#/components/schemas/CommentAutomationTemplate' }]
                  description: "Optional product card sent INSTEAD of the plain dmMessage bubble. Mutually exclusive with buttons. dmMessage stays required: it is what gets sent the moment the card is cleared."
                commentReply: { type: string, description: Optional public reply to the comment }
                dmMessageVariations:
                  type: array
                  maxItems: 5
                  items: { type: string }
                  description: "Optional alternate DM texts for random rotation. When set, each triggered comment sends one picked at random from [dmMessage, ...dmMessageVariations], so repeat commenters get slightly different DMs (helps avoid identical-message patterns). Up to 5. Buttons are attached to whichever text is picked, not varied."
                commentReplyVariations:
                  type: array
                  maxItems: 5
                  items: { type: string }
                  description: "Optional alternate public replies, rotated at random alongside commentReply (picked independently of the DM). Up to 5."
                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." }
                dmDelaySeconds: { type: integer, minimum: 0, maximum: 86400, description: "Seconds to wait after the trigger before sending the DM. Omit or send 0 to reply immediately (the default). Max 86400 (24h). The trigger is still matched and deduplicated the moment the comment arrives, so a delay only moves when the response is sent." }
                commentReplyDelaySeconds: { type: integer, minimum: 0, maximum: 86400, description: "Seconds to wait before posting the public comment reply. Omit or send 0 to post it right after the DM (the default). The reply never goes out before the DM, so a value below dmDelaySeconds is raised to it. Ignored when trigger=story_reply, which has no public reply." }
                alsoMatchInDms: { type: boolean, default: false, description: "Also fire these keywords on a plain inbound DM, so the automation answers people who message the keyword instead of commenting it. Requires at least one keyword (an empty keyword list means 'match anything', which would answer every inbound message) and is rejected on story_reply automations, which already trigger on DMs. Dedup is per door: a contact who already received the DM from their comment can still receive it from a DM." }
                audience: { $ref: '#/components/schemas/CommentAutomationAudience' }
                followGate: { $ref: '#/components/schemas/CommentAutomationFollowGate' }
      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, word], description: "How a keyword is compared with the comment. 'contains' (default) matches anywhere, even inside another word (keyword 'app' fires on 'happy'). 'word' matches the keyword only as a standalone word. 'exact' requires the whole comment to be exactly the keyword." }
                      excludeKeywords: { type: array, items: { type: string }, description: "Comments containing one of these never trigger the automation, even when a trigger keyword also matches. Compared using the same matchMode." }
                      typoTolerance: { type: boolean, description: "Only with matchMode=word: also fire on close misspellings of a keyword (one edit for 4-7 character keywords, two from 8 up). Keywords shorter than 4 characters are never fuzzy-matched." }
                      dmMessage: { type: string }
                      buttons:
                        type: array
                        items: { $ref: '#/components/schemas/DmButton' }
                        description: Inline DM buttons (up to 3). Omitted when none are set.
                      template:
                        $ref: '#/components/schemas/CommentAutomationTemplate'
                      commentReply: { type: string }
                      dmMessageVariations: { type: array, items: { type: string }, description: "Alternate DM texts rotated at random with dmMessage. Omitted when none." }
                      commentReplyVariations: { type: array, items: { type: string }, description: "Alternate public replies rotated at random with commentReply. Omitted when none." }
                      linkTracking: { type: boolean }
                      clickTag: { type: string }
                      dmDelaySeconds: { type: integer, description: "Seconds waited after the trigger before the DM is sent. Absent when the DM goes out immediately." }
                      commentReplyDelaySeconds: { type: integer, description: "Seconds waited before the public reply is posted. Absent when it follows the DM immediately." }
                      audience: { $ref: '#/components/schemas/CommentAutomationAudience' }
                      followGate: { $ref: '#/components/schemas/CommentAutomationFollowGate' }
                      alsoMatchInDms: { type: boolean, description: "Whether these keywords also fire on a plain inbound DM." }
                      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:
      x-resource-group: "messages"
      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, word], description: "How a keyword is compared with the comment. 'contains' (default) matches anywhere, even inside another word (keyword 'app' fires on 'happy'). 'word' matches the keyword only as a standalone word. 'exact' requires the whole comment to be exactly the keyword." }
                      excludeKeywords: { type: array, items: { type: string }, description: "Comments containing one of these never trigger the automation, even when a trigger keyword also matches. Compared using the same matchMode." }
                      typoTolerance: { type: boolean, description: "Only with matchMode=word: also fire on close misspellings of a keyword (one edit for 4-7 character keywords, two from 8 up). Keywords shorter than 4 characters are never fuzzy-matched." }
                      dmMessage: { type: string }
                      buttons:
                        type: array
                        items: { $ref: '#/components/schemas/DmButton' }
                        description: Inline DM buttons (up to 3). Omitted when none are set.
                      template:
                        $ref: '#/components/schemas/CommentAutomationTemplate'
                      commentReply: { type: string }
                      dmMessageVariations: { type: array, items: { type: string }, description: "Alternate DM texts rotated at random with dmMessage. Omitted when none." }
                      commentReplyVariations: { type: array, items: { type: string }, description: "Alternate public replies rotated at random with commentReply. Omitted when none." }
                      linkTracking: { type: boolean }
                      clickTag: { type: string }
                      dmDelaySeconds: { type: integer, description: "Seconds waited after the trigger before the DM is sent. Absent when the DM goes out immediately." }
                      commentReplyDelaySeconds: { type: integer, description: "Seconds waited before the public reply is posted. Absent when it follows the DM immediately." }
                      audience: { $ref: '#/components/schemas/CommentAutomationAudience' }
                      followGate: { $ref: '#/components/schemas/CommentAutomationFollowGate' }
                      alsoMatchInDms: { type: boolean, description: "Whether these keywords also fire on a plain inbound DM." }
                      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 }
                        source: { type: string, enum: [comment, story_reply, dm], description: "Which door triggered this send. Absent on rows written before this field existed (all of those are comment-triggered)." }
                        status: { type: string, enum: [pending, sent, failed, skipped, gated], description: "DM outcome. 'pending' = the automation has a dmDelaySeconds and the response is queued but not sent yet. 'gated' = the follow-gate confirmation DM went out and we are waiting for the tap; it flips to 'sent' or 'skipped' when they tap." }
                        audienceOutcome: { type: string, enum: [passed, blocked, gate_sent, gate_passed, gate_failed], description: "How the audience rule resolved. Absent on automations without one." }
                        commenterIsFollower: { type: boolean, description: "Follow relationship at decision time. Absent when Instagram would not tell us (the commenter never messaged the account)." }
                        commenterFollowerCount: { type: integer }
                        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 }
                        nextDueAt: { type: string, format: date-time, description: "When the next queued send fires. Present only while something is still pending." }
                        createdAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    patch:
      x-resource-group: "messages"
      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 }
                trigger: { type: string, enum: [comment, story_reply], description: "What fires the automation. Changing it detaches the automation from its bound post or story (a post id and a story id are different objects), unless this same request sets a new binding. 'story_reply' is Instagram only." }
                keywords: { type: array, items: { type: string } }
                matchMode: { type: string, enum: [exact, contains, word], description: "How a keyword is compared with the comment. 'contains' (default) matches anywhere, even inside another word (keyword 'app' fires on 'happy'). 'word' matches the keyword only as a standalone word. 'exact' requires the whole comment to be exactly the keyword." }
                excludeKeywords: { type: array, items: { type: string }, description: "Comments containing one of these never trigger the automation, even when a trigger keyword also matches. Compared using the same matchMode." }
                typoTolerance: { type: boolean, description: "Only with matchMode=word: also fire on close misspellings of a keyword (one edit for 4-7 character keywords, two from 8 up). Keywords shorter than 4 characters are never fuzzy-matched." }
                dmMessage: { type: string }
                buttons:
                  type: array
                  maxItems: 3
                  items: { $ref: '#/components/schemas/DmButton' }
                  description: "Inline DM buttons (1-3). Pass [] to clear all buttons."
                template:
                  oneOf:
                    - $ref: '#/components/schemas/CommentAutomationTemplate'
                    - type: 'null'
                  description: "Product card sent instead of the plain dmMessage bubble. Pass null to clear it and fall back to dmMessage. Mutually exclusive with buttons, including with the buttons already stored on the automation."
                commentReply: { type: string }
                dmMessageVariations:
                  type: array
                  maxItems: 5
                  items: { type: string }
                  description: "Alternate DM texts for random rotation (see create). Pass [] to clear."
                commentReplyVariations:
                  type: array
                  maxItems: 5
                  items: { type: string }
                  description: "Alternate public replies for random rotation. Pass [] to clear."
                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." }
                alsoMatchInDms: { type: boolean, description: "Also fire these keywords on a plain inbound DM. Enabling it requires the automation to end up with at least one keyword (this request's keywords if you send them, otherwise the stored ones) and is rejected on story_reply automations." }
                dmDelaySeconds: { type: integer, minimum: 0, maximum: 86400, description: "Seconds to wait after the trigger before sending the DM. Send 0 to clear the delay and reply immediately." }
                commentReplyDelaySeconds: { type: integer, minimum: 0, maximum: 86400, description: "Seconds to wait before posting the public comment reply. Send 0 to clear it. The reply never goes out before the DM." }
                audience: { $ref: '#/components/schemas/CommentAutomationAudience' }
                followGate: { $ref: '#/components/schemas/CommentAutomationFollowGate' }
                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, word], description: "How a keyword is compared with the comment. 'contains' (default) matches anywhere, even inside another word (keyword 'app' fires on 'happy'). 'word' matches the keyword only as a standalone word. 'exact' requires the whole comment to be exactly the keyword." }
                      excludeKeywords: { type: array, items: { type: string }, description: "Comments containing one of these never trigger the automation, even when a trigger keyword also matches. Compared using the same matchMode." }
                      typoTolerance: { type: boolean, description: "Only with matchMode=word: also fire on close misspellings of a keyword (one edit for 4-7 character keywords, two from 8 up). Keywords shorter than 4 characters are never fuzzy-matched." }
                      dmMessage: { type: string }
                      buttons:
                        type: array
                        items: { $ref: '#/components/schemas/DmButton' }
                        description: Inline DM buttons (up to 3). Omitted when none are set.
                      template:
                        $ref: '#/components/schemas/CommentAutomationTemplate'
                      commentReply: { type: string }
                      dmMessageVariations: { type: array, items: { type: string }, description: "Alternate DM texts rotated at random with dmMessage. Omitted when none." }
                      commentReplyVariations: { type: array, items: { type: string }, description: "Alternate public replies rotated at random with commentReply. Omitted when none." }
                      audience: { $ref: '#/components/schemas/CommentAutomationAudience' }
                      followGate: { $ref: '#/components/schemas/CommentAutomationFollowGate' }
                      alsoMatchInDms: { type: boolean, description: "Whether these keywords also fire on a plain inbound DM." }
                      isActive: { type: boolean }
                      updatedAt: { type: string, format: date-time }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }
    delete:
      x-resource-group: "messages"
      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:
      x-resource-group: "messages"
      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: [pending, sent, failed, skipped, gated] }, description: Filter by result status }
        - { name: limit, in: query, schema: { type: integer, default: 50, minimum: 1, maximum: 200 } }
        - { name: skip, in: query, schema: { type: integer, default: 0, minimum: 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 }
                        source: { type: string, enum: [comment, story_reply, dm], description: "Which door triggered this send. Absent on rows written before this field existed (all of those are comment-triggered)." }
                        status: { type: string, enum: [pending, sent, failed, skipped, gated], description: "DM outcome. 'pending' = the automation has a dmDelaySeconds and the response is queued but not sent yet. 'gated' = the follow-gate confirmation DM went out and we are waiting for the tap; it flips to 'sent' or 'skipped' when they tap." }
                        audienceOutcome: { type: string, enum: [passed, blocked, gate_sent, gate_passed, gate_failed], description: "How the audience rule resolved. Absent on automations without one." }
                        commenterIsFollower: { type: boolean, description: "Follow relationship at decision time. Absent when Instagram would not tell us (the commenter never messaged the account)." }
                        commenterFollowerCount: { type: integer }
                        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 }
                        nextDueAt: { type: string, format: date-time, description: "When the next queued send fires. Present only while something is still pending." }
                        createdAt: { type: string, format: date-time }
                  pagination:
                    type: object
                    properties:
                      total: { type: integer }
                      limit: { type: integer }
                      skip: { type: integer }
                      hasMore: { type: boolean }
                  misses:
                    type: object
                    description: "Comments that reached this automation but matched none of its keywords. These produce no log entry, so this is the only signal that a keyword is catching nothing. Retained for a short window, then dropped."
                    properties:
                      total: { type: integer, description: Number of non-matching comments in the retention window }
                      retentionDays: { type: integer, description: How many days of non-matching comments the total covers }
                      samples:
                        type: array
                        description: A few of the most recent non-matching comments, for diagnosing a keyword setup.
                        items:
                          type: object
                          properties:
                            commentText: { type: string }
                            commenterName: { type: string }
                            excludedBy: { type: string, description: Set when an exclusion keyword vetoed an otherwise matching comment }
                            at: { type: string, format: date-time }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

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

  /v1/ads:
    get:
      x-resource-group: "ads"
      operationId: listAds
      tags: ["Ad Campaigns"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      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, openai] } }
        - { 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: pageId, in: query, schema: { type: string }, description: "Meta only: Facebook Page ID. Returns only ads whose creative is backed by this Page (a Meta ad account serves ads for every Page in the Business Manager). Matches each ad's `creative.pageId`; ads with no page signal (rare IG-only creatives) never match. 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' }
                  backfillPending:
                    type: boolean
                    description: "Present and true only on `202` responses: part of the requested date range is still being backfilled from the platform in the background. Retry the same request shortly; it returns 200 once the range is fully ingested."
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '202':
          description: "Part of the requested date range predates the ingested history; a background backfill job has been queued. The body has the same shape as the 200 response, carries the currently-available data, and includes `backfillPending: true`. A `Retry-After` header carries the recommended poll interval in seconds. Allow the job a short time to run (typically 1-3 minutes) and submit the request again; once ingestion completes the same request returns 200 with the full range."
        '400': { $ref: '#/components/responses/BadRequest' }
        '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/keywords:
    get:
      x-resource-group: "ads"
      operationId: listAdKeywords
      tags: ["Ad Campaigns"]
      x-platforms: ["google"]
      summary: List Search keywords
      description: |
        Returns the Google Search keyword criteria (positive and negative) synced from
        connected Google Ads accounts, one row per ad-group keyword. Refreshed about
        once a week per Google Ads customer (the keyword sweep rides the ads discovery
        pass on a slower slot, to stay inside Google's shared daily API quota), so
        keywords added on Google can take several days to appear. A customer synced
        for the first time is populated on the next discovery pass rather than
        waiting for its weekly slot, and connecting an account or triggering a
        manual sync refreshes it immediately.
        Campaign-level negative keywords are not included; only ad-group-level
        criteria are.
      security:
        - bearerAuth: []
      parameters:
        - $ref: '#/components/parameters/PageParam'
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 50 } }
        - { name: accountId, in: query, schema: { type: string }, description: Social account ID }
        - { name: adAccountId, in: query, schema: { type: string }, description: 'Platform ad account ID (Google customer ID). Mirrors the same filter on /v1/ads.' }
        - { name: profileId, in: query, schema: { type: string }, description: Profile ID }
        - { name: campaignId, in: query, schema: { type: string }, description: Platform campaign ID }
        - { name: adSetId, in: query, schema: { type: string }, description: 'Platform ad group ID (Google ad group)' }
        - { name: status, in: query, schema: { type: string, enum: [active, paused] }, description: Keyword criterion status }
        - { name: matchType, in: query, schema: { type: string, enum: [exact, phrase, broad, unknown] } }
        - { name: negative, in: query, schema: { type: boolean }, description: 'true = negative keywords only, false = positive only. Omit for both.' }
        - { name: search, in: query, schema: { type: string, maxLength: 200 }, description: 'Case-insensitive substring match on the keyword text' }
      responses:
        '200':
          description: Paginated keywords
          content:
            application/json:
              schema:
                type: object
                properties:
                  keywords:
                    type: array
                    items:
                      type: object
                      properties:
                        id: { type: string }
                        accountId: { type: string, description: Social account ID owning the sync }
                        profileId: { type: string }
                        platform: { type: string, enum: [google] }
                        adAccountId: { type: string, description: Google customer ID }
                        campaignId: { type: string }
                        campaignName: { type: [string, "null"] }
                        campaignStatus: { type: [string, "null"] }
                        adSetId: { type: string, description: Google ad group ID }
                        adSetName: { type: [string, "null"] }
                        adSetStatus: { type: [string, "null"] }
                        keyword: { type: string }
                        matchType: { type: string, enum: [exact, phrase, broad, unknown] }
                        status: { type: string, enum: [active, paused] }
                        negative: { type: boolean }
                        syncedAt: { type: [string, "null"], format: date-time }
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '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:
      x-resource-group: "ads"
      operationId: listAdCampaigns
      tags: ["Ad Campaigns"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      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:
        - name: includeEmpty
          in: query
          required: false
          schema: { type: boolean }
          description: >-
            Meta only. Campaign reads aggregate over ad documents, so a campaign
            with ZERO ads is normally invisible here — the state the two-step
            create (campaign, then ads via `existingCampaignId`) leaves behind
            whenever Meta rejects the ad step. Set true to list those too, with
            `adCount: 0` and zeroed metrics. Requires `accountId` and
            `adAccountId`, since an empty campaign has no ad row to resolve a
            token or ad account from.
        - $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, openai] } }
        - { 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: pageId, in: query, schema: { type: string }, description: "Meta only: Facebook Page ID. Campaigns have no Page of their own, so this keeps campaigns having at least one ad backed by this Page, with adCount and metrics computed over those ads only. Mirrors the same filter on /v1/ads and /v1/ads/tree." }
        - { 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' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '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:
      x-resource-group: "ads"
      operationId: createAdCampaign
      tags: ["Ad Campaigns"]
      x-platforms: ["meta"]
      summary: Create a standalone campaign
      description: |-
        Creates a campaign WITHOUT its first ad set / ad (the ODAX shell only). Ad sets join it
        later via `existingCampaignId` on the create endpoints. A budget here is campaign-level
        (CBO) by definition; omit it for ABO (each ad set carries its own budget). Created
        `PAUSED` unless `status: ACTIVE`. The campaign materializes in `/v1/ads/tree` via the
        next sync discovery pass.

        **Idempotency:** send an `Idempotency-Key` header to make retries safe.
      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
            retries safe. Same key + same body replays the original response;
            same key + different body → 422; key still processing → 409. Only
            2xx responses are stored, so a request that failed with a 4xx can
            be retried with a corrected body under the SAME key.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, adAccountId, name, goal]
              properties:
                accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
                adAccountId: { type: string, description: "Meta ad account id (act_<n>)." }
                name: { type: string, maxLength: 255 }
                goal:
                  type: string
                  enum: [engagement, traffic, awareness, video_views, lead_generation, lead_conversion, job_applicants, conversions, app_promotion, catalog_sales]
                  description: Mapped to the ODAX objective (same mapping as POST /v1/ads/create).
                specialAdCategories:
                  type: array
                  items: { type: string, enum: [HOUSING, EMPLOYMENT, CREDIT, ISSUES_ELECTIONS_POLITICS, FINANCIAL_PRODUCTS_SERVICES, ONLINE_GAMBLING_AND_GAMING] }
                budgetAmount: { type: number, description: "Campaign-level (CBO) budget in WHOLE currency units (USD: 50 = $50.00), NOT cents — Meta's own Marketing API takes this same number in minor units, so it is an easy and expensive mix-up. Requires budgetType." }
                budgetType: { type: string, enum: [daily, lifetime] }
                status: { type: string, enum: [ACTIVE, PAUSED], default: PAUSED }
                bidStrategy:
                  type: string
                  enum: [LOWEST_COST_WITHOUT_CAP, LOWEST_COST_WITH_BID_CAP, COST_CAP, LOWEST_COST_WITH_MIN_ROAS]
                  description: "Campaign bid strategy. Meta stores `bid_strategy` alongside the budget, so this REQUIRES `budgetAmount` + `budgetType` on the same request; sending it without a campaign budget is a 400. A campaign carrying a strategy without its `bid_amount` makes every ad set created under it fail with an error that names the ad set (code 100, subcode 1815857), so the bad state is rejected up front rather than accepted. To bid at ad-set level, set the strategy there instead."
                bidAmount: { type: number, description: "Whole currency units (USD: 5 = $5.00). Required for LOWEST_COST_WITH_BID_CAP and COST_CAP; ignored otherwise." }
                roasAverageFloor: { type: number, description: "Decimal ROAS multiplier (2.0 = 2.0x). Required for LOWEST_COST_WITH_MIN_ROAS." }
      responses:
        '201':
          description: Campaign created
          content:
            application/json:
              schema:
                type: object
                properties:
                  adAccountId: { type: string }
                  campaignId: { type: string, description: Platform id of the new campaign }
                  objective: { type: string, description: "Resolved ODAX objective (e.g. OUTCOME_SALES)." }
                  status: { type: string, enum: [ACTIVE, PAUSED] }
        '400': { description: "Invalid input, or Meta rejected the create" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/campaigns/{campaignId}/status:
    put:
      x-resource-group: "ads"
      operationId: updateAdCampaignStatus
      tags: ["Ad Campaigns"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x", "openai"]
      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, openai] }
      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:
      x-resource-group: "ads"
      operationId: updateAdCampaign
      tags: ["Ad Campaigns"]
      x-platforms: ["meta", "google"]
      summary: Update a campaign
      description: |
        Campaign-level edits. Send at least one of `budget`, `bidStrategy`, `name`
        or `platformSpecificData`. An unsupported field is always an error, never a
        silent drop.

        | Body field | Meta | Google | Others |
        |---|---|---|---|
        | `bidStrategy` | Yes | Yes | 501 |
        | `bidAmount`, `roasAverageFloor` | 400 — ad-set level | Yes | 400 |
        | `budget` (CBO; ABO returns 409) | Yes | 501 | 501 |
        | `name` | Yes | 501 | 501 |
        | `platformSpecificData.spendCap` | Yes | 400 | 400 |
        | `accountId` (empty campaigns) | Yes | - | - |

        Google maps the shared enum onto its own strategies: `LOWEST_COST_WITHOUT_CAP`
        to Maximize Clicks, `LOWEST_COST_WITH_BID_CAP` to Maximize Clicks with a max
        CPC (`bidAmount`), `COST_CAP` to Target CPA (`bidAmount`),
        `LOWEST_COST_WITH_MIN_ROAS` to Target ROAS (`roasAverageFloor`). A campaign on
        a PORTFOLIO bidding strategy is rejected: detach it in Google Ads first, since
        it is shared across campaigns.

        `accountId` forwards the update straight to Meta for a campaign with zero ads,
        which would otherwise 404; the response then carries `updated: 0`.
      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, google], description: "Required: platform campaign IDs are not globally unique." }
                accountId: { type: string, description: "**Meta only.** Zernio SocialAccount id owning the ad account. Needed only for an EMPTY campaign (zero ads); ignored otherwise." }
                bidStrategy:
                  allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
                  description: "**Meta + Google.** On Meta, the campaign default that ad sets inherit unless they override it. On Google, the campaign's own bidding strategy."
                bidAmount: { type: number, description: "**Google only.** Whole currency units (USD: 12 = $12.00). Max CPC for LOWEST_COST_WITH_BID_CAP, CPA target for COST_CAP; required for both." }
                roasAverageFloor: { type: number, description: "**Google only.** Decimal ROAS multiplier (2.0 = 2.0x), required for LOWEST_COST_WITH_MIN_ROAS." }
                budget:
                  type: object
                  description: "**Meta only.** The CBO budget."
                  required: [amount, type]
                  properties:
                    amount: { type: number, description: Budget amount in the ad account's currency }
                    type: { type: string, enum: [daily, lifetime] }
                name: { type: string, maxLength: 255, description: "**Meta only.** Rename the campaign." }
                platformSpecificData:
                  type: object
                  description: "**Meta only.** Platform implied by the `platform` body param, same convention as POST /v1/ads/create."
                  properties:
                    spendCap: { type: [number, "null"], description: "Campaign lifetime spend cap, in the ad account's currency (Meta `spend_cap`). Pass null to remove the cap; 0 is rejected by Meta." }
      responses:
        '200':
          description: Campaign updated
          content:
            application/json:
              schema:
                type: object
                description: Echoes back only the fields you sent, plus `updated`.
                properties:
                  updated: { type: integer, description: "Local Ad documents mirrored. 0 on the empty-campaign path." }
                  budget: { $ref: '#/components/schemas/AdBudget' }
                  budgetLevel: { type: string, enum: [campaign] }
                  bidStrategy: { $ref: '#/components/schemas/BidStrategy' }
                  bidAmount: { type: number }
                  roasAverageFloor: { type: number }
                  platformSpecificData: { type: object }
        '400': { description: "Invalid input, or a field the resolved platform does not support at the campaign level (see the support table)" }
        '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:
      x-resource-group: "ads"
      operationId: deleteAdCampaign
      tags: ["Ad Campaigns"]
      x-platforms: ["meta", "tiktok"]
      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`.

        **Empty campaigns.** A campaign with zero ads has no local Ad documents
        to resolve, so it is invisible to `/v1/ads/tree` and this endpoint would
        404. That state is produced by the two-step create flow (campaign, then
        ads via `existingCampaignId`) whenever Meta rejects the ad step. To
        delete such a shell, send `accountId` in the body: we skip the local
        lookup entirely and forward the delete to Meta. `accountId` is ignored
        when the campaign does have ads.
      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] }
                accountId: { type: string, description: "Zernio SocialAccount id owning the ad account. Required only to delete an EMPTY campaign (zero ads), which has no local Ad documents to resolve a token from." }
      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:
      x-resource-group: "ads"
      operationId: bulkUpdateAdCampaignStatus
      tags: ["Ad Campaigns"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      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, openai] }
      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:
      x-resource-group: "ads"
      operationId: duplicateAdCampaign
      tags: ["Ad Campaigns"]
      x-platforms: ["meta", "tiktok", "linkedin"]
      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.
        - **LinkedIn** has no native copy primitive; Zernio walks the source
          CampaignGroup → Campaigns → Creatives and recreates each entity,
          carrying over `type` / `costType` / `unitCost` /
          `optimizationTargetType` / `creativeSelection` / `objectiveType` /
          `format` / `dailyBudget` / `totalBudget` / `targetingCriteria` /
          `runSchedule` and every Creative's `content` object verbatim.
          `statusOption: INHERITED_FROM_SOURCE` is evaluated **per entity**:
          any Group / Campaign / Creative whose source is `ACTIVE` gets its
          clone activated too. Duplicating an ACTIVE campaign with
          `INHERITED_FROM_SOURCE` starts a second front of spend the moment
          the clone activates — the safe default is `PAUSED`.

        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: Idempotency-Key
          in: header
          required: false
          schema: { type: string, maxLength: 255 }
          description: >-
            Optional client-generated unique key (e.g. a UUID) that makes
            retries safe. Same key + same body replays the original response;
            same key + different body → 422; key still processing → 409. Only
            2xx responses are stored, so a request that failed with a 4xx can
            be retried with a corrected body under the SAME key.
        - { 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, linkedin] }
                deepCopy: { type: boolean, default: true, description: Copy child ad sets + ads + creatives + targeting }
                statusOption:
                  type: string
                  enum: [ACTIVE, PAUSED, INHERITED_FROM_SOURCE]
                  default: PAUSED
                  description: >
                    ACTIVE = launch the clone immediately (spends the moment
                    LinkedIn approves it). PAUSED = clone stays DRAFT, safe
                    default. INHERITED_FROM_SOURCE = mirror each entity's
                    source status per-entity. Duplicating an ACTIVE
                    campaign this way starts a second front of spend.
                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}/duplicate:
    post:
      x-resource-group: "ads"
      operationId: duplicateAdSet
      tags: ["Ad Campaigns"]
      x-platforms: ["meta"]
      summary: Duplicate an ad set
      description: |-
        Duplicates an ad set, including its ads and creatives by default (`deepCopy: true`),
        via Meta's native `POST /{adset-id}/copies`. The copy is created paused so callers can
        review before launching. `campaignId` retargets the copy into another campaign; omitted
        = the source's own campaign. The new hierarchy materializes asynchronously — sync
        discovery is triggered automatically (`syncAfter: false` to skip).
      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
            retries safe. Same key + same body replays the original response;
            same key + different body → 422; key still processing → 409. Only
            2xx responses are stored, so a request that failed with a 4xx can
            be retried with a corrected body under the SAME key.
        - { name: adSetId, in: path, required: true, schema: { type: string }, description: Source platform ad set ID }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [platform]
              properties:
                platform: { type: string, enum: [facebook, instagram] }
                campaignId: { type: string, description: Destination platform campaign id (defaults to the source's campaign) }
                deepCopy: { type: boolean, default: true, description: Copy child ads + creatives }
                statusOption: { type: string, enum: [ACTIVE, PAUSED, INHERITED_FROM_SOURCE], default: PAUSED }
                startTime: { type: string, format: date-time, description: Reschedule the copy'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 }
      responses:
        '200':
          description: Ad set duplicated
          content:
            application/json:
              schema:
                type: object
                properties:
                  copiedAdSetId: { type: string, description: Platform ID of the new ad set }
                  discovery: { type: string, enum: [triggered, skipped, failed] }
                  raw: { type: object, description: Meta's native copy response (includes ad_object_ids for child copies) }
        '400': { description: Invalid input }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Source ad set not found }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/{adId}/duplicate:
    post:
      x-resource-group: "ads"
      operationId: duplicateAd
      tags: ["Ad Campaigns"]
      x-platforms: ["meta"]
      summary: Duplicate an ad
      description: |-
        Duplicates a single ad via Meta's native `POST /{ad-id}/copies`. The copy is created
        paused. `adSetId` retargets the copy into another ad set; omitted = the source's own ad
        set. Accepts the Zernio ad id or the platform ad id. Sync discovery is triggered
        automatically (`syncAfter: false` to skip).
      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
            retries safe. Same key + same body replays the original response;
            same key + different body → 422; key still processing → 409. Only
            2xx responses are stored, so a request that failed with a 4xx can
            be retried with a corrected body under the SAME key.
        - { name: adId, in: path, required: true, schema: { type: string }, description: Zernio ad ID or platform ad ID }
      requestBody:
        required: false
        content:
          application/json:
            schema:
              type: object
              properties:
                adSetId: { type: string, description: Destination platform ad set id (defaults to the source's ad set) }
                statusOption: { type: string, enum: [ACTIVE, PAUSED, INHERITED_FROM_SOURCE], default: PAUSED }
                renameStrategy: { type: string, enum: [DEEP_RENAME, ONLY_TOP_LEVEL_RENAME, NO_RENAME] }
                renamePrefix: { type: string }
                renameSuffix: { type: string }
                syncAfter: { type: boolean, default: true }
      responses:
        '200':
          description: Ad duplicated
          content:
            application/json:
              schema:
                type: object
                properties:
                  copiedAdId: { type: string, description: Platform ID of the new ad }
                  discovery: { type: string, enum: [triggered, skipped, failed] }
                  raw: { type: object }
        '400': { description: Invalid input }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Ad not found }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/ad-sets/{adSetId}:
    get:
      x-resource-group: "ads"
      operationId: getAdSetDetails
      tags: ["Ad Campaigns"]
      x-platforms: ["meta"]
      summary: Live ad-set details incl. learning phase
      description: |-
        Reads the ad set live from Meta, returned verbatim. The default projection includes
        `learning_stage_info` (learning-phase status: LEARNING / SUCCESS / FAIL / WAIVING — Meta
        omits its `status` key on paused ad sets), delivery settings, budgets, schedule and
        targeting. `fields` is a raw-passthrough override; unknown fields return Meta's 400
        verbatim.
      security:
        - bearerAuth: []
      parameters:
        - { name: adSetId, in: path, required: true, schema: { type: string }, description: "Meta ad set id (platformAdSetId)." }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
        - { name: fields, in: query, schema: { type: string }, description: "Comma-separated Graph field override (supports nested {} projections)." }
      responses:
        '200':
          description: The ad set as returned by Meta
          content:
            application/json:
              schema:
                type: object
                properties:
                  adSet: { type: object, description: "Raw Meta ad set; keys are the requested Graph fields." }
        '400': { description: "Invalid input, or Meta rejected the query — message carries Meta's error" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }
    put:
      x-resource-group: "ads"
      operationId: updateAdSet
      tags: ["Ad Campaigns"]
      x-platforms: ["meta", "tiktok", "openai"]
      summary: Update an ad set
      description: |
        Ad-set-level writes. Use this for ABO budget updates, ad-set-scoped
        pause/resume, bid-strategy edits, Meta value-rule-set attach/detach, and
        Meta-only post-launch delivery settings via `platformSpecificData`. At
        least one updatable field is required.

        Value rule sets (Meta only, see `/v1/ads/value-rule-sets`):
        - ATTACH or REPLACE: send `valueRuleSetId`. Attachment is driven by the id's
          presence, so `valueRulesApplied: true` is optional. Sending a different id
          replaces the previous association; there is no separate replace call.
        - DETACH: send `valueRulesApplied: false` and OMIT `valueRuleSetId`.
        - Sending `valueRulesApplied: false` TOGETHER with `valueRuleSetId` returns 400
          `mutually_exclusive_fields`. This is deliberate: Meta attaches the rule set
          whenever `value_rule_set_id` is present, even with `value_rules_applied` false,
          so echoing stored state while asking to detach would silently keep the bid
          adjustments live.
        - Eligibility: only ad sets on `LOWEST_COST_WITHOUT_CAP` or `COST_CAP`. Meta
          rejects the rest server-side.
        - Read back with `GET /v1/ads/ad-sets/{adSetId}?fields=value_rule_set_id`. Meta
          does not document `value_rules_applied` as a readable ad-set field, so the
          boolean cannot be read back.

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

        Delivery settings are validated by Meta against the campaign objective;
        incompatible combinations (e.g. a billingEvent the optimization goal
        doesn't allow) surface as 400s from Meta.

        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, openai] }
                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), TikTok, and OpenAI. On TikTok the
                    Meta-style enum is mapped to bid_type / bid_price / deep_bid_type
                    automatically. On OpenAI, LOWEST_COST_WITH_BID_CAP and COST_CAP both map to
                    the ad group's `bidding_config.max_bid_micros` (one knob covers both);
                    LOWEST_COST_WITH_MIN_ROAS is rejected with 422 (OpenAI has no ROAS-based
                    bidding). 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, or (on OpenAI) to micros (× 1,000,000).
                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.
                    Not supported on OpenAI (422).
                valueRuleSetId:
                  type: string
                  pattern: '^\d+$'
                  description: |
                    Meta only (other platforms return 501). Value rule set to attach to this ad
                    set, from `/v1/ads/value-rule-sets`. Sending a different id replaces the
                    current association. To DETACH, send `valueRulesApplied: false` and omit
                    this field.
                valueRulesApplied:
                  type: boolean
                  description: |
                    Meta only (other platforms return 501). `false` DETACHES the ad set's value
                    rule set and must be sent WITHOUT `valueRuleSetId`; the combination returns
                    400. `true` is optional when attaching, since attachment is driven by
                    `valueRuleSetId`, and requires it to be present.
                platformSpecificData:
                  type: object
                  description: |
                    Platform-specific post-launch delivery settings. The platform is implied by the
                    `platform` body param. Meta only; other platforms return 400. Unknown keys are rejected.
                  properties:
                    optimizationGoal: { type: string, description: "Meta ad-set optimization_goal (e.g. OFFSITE_CONVERSIONS, LANDING_PAGE_VIEWS)." }
                    billingEvent: { type: string, description: "Meta ad-set billing_event (e.g. IMPRESSIONS, LINK_CLICKS, THRUPLAY)." }
                    startDate: { type: string, description: "Ad set start_time (ISO 8601)." }
                    endDate: { type: string, description: "Ad set end_time (ISO 8601)." }
                    promotedObject:
                      type: object
                      description: "Meta ad-set promoted_object, forwarded verbatim (same shape as /v1/ads/create). Unknown keys are rejected with 400."
                      properties:
                        pixelId: { type: string }
                        customEventType: { type: string }
                        customEventStr: { type: string, description: 'Pixel custom-event name (custom_event_str); requires customEventType OTHER. Same pairing rules as /v1/ads/create.' }
                        pageId: { type: string }
                        applicationId: { type: string }
                        objectStoreUrl: { type: string }
                        customConversionId: { type: string }
                        productCatalogId: { type: string }
                        productSetId: { type: string }
                        offlineConversionDataSetId: { type: string }
                        whatsappPhoneNumber: { type: string }
                      additionalProperties: false
      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"] }
                  platformSpecificData: { type: object }
        '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" }
        '422': { description: "bidStrategy is LOWEST_COST_WITH_MIN_ROAS on OpenAI (unsupported: no ROAS-based bidding)" }
        '501': { description: "bidStrategy not supported on the platform (Meta, TikTok, and OpenAI only)" }

  /v1/ads/ad-sets/{adSetId}/status:
    put:
      x-resource-group: "ads"
      operationId: updateAdSetStatus
      tags: ["Ad Campaigns"]
      x-platforms: ["meta", "tiktok"]
      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, openai] }
      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:
      x-resource-group: "ads"
      operationId: getAdTree
      tags: ["Ad Campaigns"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      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.

        **Deleted objects stay in the tree.** Deleting an ad or a campaign is a soft delete: the Ad
        documents move to `status: cancelled` and are kept indefinitely, so their historical spend
        still counts toward the metrics of any date range they fall in. There is no pruning job and
        no retention window. Filter on `status` if your view should hide them, but do that after
        reading the totals, not before.
      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, openai] } }
        - { 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: pageId, in: query, schema: { type: string }, description: "Meta only: Facebook Page ID. Prunes the tree to ads whose creative is backed by this Page — campaigns and ad sets with no ad on the Page drop out, and rolled-up metrics cover only the Page's ads. Mirrors the same filter on /v1/ads and /v1/ads/campaigns." }
        - { 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`, except `reach` on Meta and TikTok: the range total is the platform's de-duplicated value, so daily reach does not sum to it. 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' }
                  backfillPending:
                    type: boolean
                    description: "Present and true only on `202` responses: part of the requested date range is still being backfilled from the platform in the background. Retry the same request shortly; it returns 200 once the range is fully ingested."
                  pagination: { $ref: '#/components/schemas/Pagination' }
        '202':
          description: "Part of the requested date range predates the ingested history; a background backfill job has been queued. The body has the same shape as the 200 response, carries the currently-available data, and includes `backfillPending: true`. A `Retry-After` header carries the recommended poll interval in seconds. Allow the job a short time to run (typically 1-3 minutes) and submit the request again; once ingestion completes the same request returns 200 with the full range."
        '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:
      x-resource-group: "ads"
      operationId: getAdsTimeline
      tags: ["Ad Campaigns"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      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 ingested history return a `202` immediately with the covered part
        and `backfillPending: true` while the rest is backfilled in the background;
        repeat the request shortly until it returns 200 with full data.
      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, openai] }, 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, description: "Reach summed across the account's ads for this single day. A person seen by two ads the same day counts twice, and reach is de-duplicated per day only: do NOT sum it across days (people reached on multiple days would be double-counted)." }
                        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: number, description: "Sum of conversion events over the range. Fractional values are normal (attribution splitting + Google modeled conversions). Meta: events matching the campaign optimization goal. Google: tracked conversions. X / LinkedIn: reported website/lead conversions (added 2026-07)." }
                        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." }
                  backfillPending:
                    type: boolean
                    description: "Present and true only on `202` responses: part of the requested date range is still being backfilled from the platform in the background. Retry the same request shortly; it returns 200 once the range is fully ingested."
        '202':
          description: "Part of the requested date range predates the ingested history; a background backfill job has been queued. The body has the same shape as the 200 response, carries the currently-available data, and includes `backfillPending: true`. A `Retry-After` header carries the recommended poll interval in seconds. Allow the job a short time to run (typically 1-3 minutes) and submit the request again; once ingestion completes the same request returns 200 with the full range."
        '400': { $ref: '#/components/responses/BadRequest' }
        '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:
      x-resource-group: "ads"
      operationId: getAd
      tags: ["Ad Campaigns"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      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:
      x-resource-group: "ads"
      operationId: updateAd
      tags: ["Ad Campaigns"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      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 / OpenAI Ads**: status + budget only. Sending
          `targeting` or `creative` returns 501 with code `unsupported_platform_operation`.
          OpenAI Ads budget is lifetime-only (see `budget.type` below).
      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], description: "OpenAI Ads accepts lifetime only; sending daily returns 422." }
                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:
      x-resource-group: "ads"
      operationId: deleteAd
      tags: ["Ad Campaigns"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      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. OpenAI Ads has no delete API; the ad is archived instead (a terminal state, the closest equivalent).
      security:
        - bearerAuth: []
      parameters:
        - { name: adId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: Ad cancelled
          content:
            application/json:
              schema:
                type: object
                properties:
                  message: { type: string }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/ads/{adId}/status:
    put:
      x-resource-group: "ads"
      operationId: updateAdStatus
      tags: ["Ad Campaigns"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      summary: Pause or resume a single ad
      description: |
        Ad-scoped pause/resume — touches ONLY this ad, never its parent ad set or
        campaign (so sibling ads keep running). Thin wrapper over the `status`
        field of PUT /v1/ads/{adId}, for callers that want a URL symmetric to
        /v1/ads/campaigns/{campaignId}/status and /v1/ads/ad-sets/{adSetId}/status.

        `{adId}` accepts the same identifier dialects as GET/PUT /v1/ads/{adId}
        (Zernio hex `_id`, Meta numeric `platformAdId`, or the creative's
        effective story/media IDs). `platform` is inferred from the ad, so it's
        not required in the body. Ads in terminal statuses (rejected, completed,
        cancelled) and no-op flips (already in the target state) are skipped.
      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." }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [status]
              properties:
                status: { type: string, enum: [active, paused] }
      responses:
        '200':
          description: Ad status updated (or skipped when no change was needed)
          content:
            application/json:
              schema:
                type: object
                properties:
                  updated: { type: integer, description: "1 when the status changed, 0 when skipped" }
                  skipped: { type: integer, description: "1 when skipped (terminal status or already in target state), else 0" }
                  message: { type: string, description: "Human-readable summary (present only when skipped)" }
        '400': { description: Invalid input }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Ad not found }

  /v1/ads/campaigns/{campaignId}/analytics:
    get:
      x-resource-group: "ads"
      operationId: getCampaignAnalytics
      tags: ["Ad Insights"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      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**: age, gender, country, publisher_platform, device_platform, region,
            platform_position, impression_device, video_asset, image_asset, body_asset, title_asset.

            **LinkedIn** (firmographics): job_title, job_function, seniority, industry,
            company, company_size, country, region. Rows carry the raw pivot `value`
            plus a resolved `name`. LinkedIn serves these aggregated over the whole
            range, delays the data 12-24h, and omits segments with fewer than 3 events.
      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."
                  backfillPending:
                    type: boolean
                    description: "Present and true only on `202` responses: part of the requested date range is still being backfilled from the platform in the background. Retry the same request shortly; it returns 200 once the range is fully ingested."
                  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 }
        '202':
          description: "Part of the requested date range predates the ingested history; a background backfill job has been queued. The body has the same shape as the 200 response, carries the currently-available data, and includes `backfillPending: true`. A `Retry-After` header carries the recommended poll interval in seconds. Allow the job a short time to run (typically 1-3 minutes) and submit the request again; once ingestion completes the same request returns 200 with the full range."
        '400':
          description: "Invalid parameter (e.g. an unknown `breakdowns` dimension). The message lists the offending value(s) and the supported set."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '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/preview:
    post:
      x-resource-group: "ads"
      operationId: generateAdPreviews
      tags: ["Ad Creatives"]
      x-platforms: ["meta"]
      summary: Render pre-create ad previews
      description: |
        Renders how a creative would look per placement BEFORE any ad exists, via Meta's
        `/generatepreviews`. Provide exactly one creative source: `existingCreativeId` or `creativeSpec`.
        Each preview is an HTML `<iframe>` snippet embeddable directly. Unknown `formats` values
        return Meta's 400 verbatim.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, adAccountId]
              properties:
                accountId: { type: string, description: "Zernio SocialAccount id used to resolve the Meta token." }
                adAccountId: { type: string, description: "Meta ad account id (act_<n>)." }
                formats:
                  type: array
                  minItems: 1
                  maxItems: 10
                  items: { type: string }
                  description: "Meta ad_format values, one preview per format. Defaults to [DESKTOP_FEED_STANDARD]."
                existingCreativeId: { type: string, description: "Preview an existing ad-account creative by id. Mutually exclusive with creativeSpec." }
                creativeSpec:
                  type: object
                  additionalProperties: true
                  description: "Raw Meta creative spec forwarded verbatim to /generatepreviews. Mutually exclusive with existingCreativeId."
      responses:
        '200':
          description: Rendered previews
          content:
            application/json:
              schema:
                type: object
                properties:
                  previews:
                    type: array
                    items:
                      type: object
                      properties:
                        format: { type: string }
                        html: { type: [string, "null"], description: "Meta's <iframe> snippet; null when Meta returned no preview for the format." }
        '400': { description: "Invalid input, or Meta rejected the creative spec / ad_format — message carries Meta's error" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { description: Meta rate limit reached }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/{adId}/preview:
    get:
      x-resource-group: "ads"
      operationId: getAdPreviews
      tags: ["Ad Creatives"]
      x-platforms: ["meta"]
      summary: Render previews of an existing ad
      description: |
        Renders an EXISTING ad per placement via Meta's `/{ad_id}/previews`. Each preview is an HTML
        `<iframe>` snippet embeddable directly. Unknown `formats` values return Meta's 400 verbatim.
      security:
        - bearerAuth: []
      parameters:
        - { name: adId, in: path, required: true, schema: { type: string }, description: "Zernio ad id (24-char hex)." }
        - { name: formats, in: query, schema: { type: string }, description: "Comma-separated Meta ad_format values (max 10), one preview per format. Defaults to DESKTOP_FEED_STANDARD." }
      responses:
        '200':
          description: Rendered previews
          content:
            application/json:
              schema:
                type: object
                properties:
                  adId: { type: string }
                  previews:
                    type: array
                    items:
                      type: object
                      properties:
                        format: { type: string }
                        html: { type: [string, "null"], description: "Meta's <iframe> snippet; null when Meta returned no preview for the format." }
        '400': { description: "Invalid input, or Meta rejected the ad_format — message carries Meta's error" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Ad not found }
        '429': { description: Meta rate limit reached }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/keywords/ideas:
    post:
      x-resource-group: "ads"
      operationId: generateKeywordIdeas
      tags: ["Ad Insights"]
      x-platforms: ["google"]
      summary: Generate keyword ideas (Google Keyword Planner)
      description: |
        Google Ads only. Runs Keyword Planner's generateKeywordIdeas from seed keywords, a seed URL,
        or both, returning idea rows verbatim (avgMonthlySearches, competition, competitionIndex,
        top-of-page bid micros, monthlySearchVolumes). Counters are int64s encoded as strings; bid
        values are micros of the account currency. Omitting `countries` targets worldwide.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId]
              properties:
                accountId: { type: string, description: "Zernio googleads SocialAccount id." }
                customerId: { type: string, description: "Numeric Google Ads customer id (no dashes); only needed when the connection has several accounts." }
                seedKeywords: { type: array, items: { type: string, maxLength: 80 }, minItems: 1, maxItems: 20, description: "Seed terms. Provide these, seedUrl, or both." }
                seedUrl: { type: string, format: uri, description: "Landing page to mine for ideas. Provide this, seedKeywords, or both." }
                countries: { type: array, items: { type: string, minLength: 2, maxLength: 2 }, minItems: 1, maxItems: 10, description: "ISO 3166-1 alpha-2 country codes. Omitted = worldwide." }
                languageConstantId: { type: string, default: "1000", description: "Google languageConstant id (1000 = English)." }
                network: { type: string, enum: [GOOGLE_SEARCH, GOOGLE_SEARCH_AND_PARTNERS], default: GOOGLE_SEARCH }
                includeAdultKeywords: { type: boolean }
                pageSize: { type: integer, minimum: 1, maximum: 10000 }
                pageToken: { type: string, description: "Cursor from paging.nextPageToken of the previous page." }
      responses:
        '200':
          description: Keyword idea rows (raw Keyword Planner shape)
          content:
            application/json:
              schema:
                type: object
                properties:
                  customerId: { type: string, description: "The customer the request ran against." }
                  data:
                    type: array
                    items: { type: object, description: "Raw GenerateKeywordIdeaResult: text + keywordIdeaMetrics." }
                  aggregateMetricResults: { type: [object, "null"] }
                  paging:
                    type: object
                    properties:
                      nextPageToken: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
        '400': { description: "Invalid input, or Google rejected the request — message carries Google's error" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { description: "Per-user Google Ads operations budget or the shared Google quota reached; the message says which and when it resets." }
        '501': { description: Only supported on Google Ads }

  /v1/ads/keywords/historical-metrics:
    post:
      x-resource-group: "ads"
      operationId: generateKeywordHistoricalMetrics
      tags: ["Ad Insights"]
      x-platforms: ["google"]
      summary: Historical keyword metrics (Google Keyword Planner)
      description: |
        Google Ads only. Runs Keyword Planner's generateKeywordHistoricalMetrics for up to 1,000
        exact keywords: historical search volume, competition and top-of-page bid ranges, plus
        averageCpcMicros when includeAverageCpc is set. Rows come back verbatim; counters are int64s
        encoded as strings, bid/CPC values are micros of the account currency.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, keywords]
              properties:
                accountId: { type: string, description: "Zernio googleads SocialAccount id." }
                customerId: { type: string, description: "Numeric Google Ads customer id (no dashes); only needed when the connection has several accounts." }
                keywords: { type: array, items: { type: string, maxLength: 80 }, minItems: 1, maxItems: 1000 }
                countries: { type: array, items: { type: string, minLength: 2, maxLength: 2 }, minItems: 1, maxItems: 10, description: "ISO 3166-1 alpha-2 country codes. Omitted = worldwide." }
                languageConstantId: { type: string, default: "1000", description: "Google languageConstant id (1000 = English)." }
                network: { type: string, enum: [GOOGLE_SEARCH, GOOGLE_SEARCH_AND_PARTNERS], default: GOOGLE_SEARCH }
                includeAdultKeywords: { type: boolean }
                includeAverageCpc: { type: boolean, description: "Adds averageCpcMicros to each row's keywordMetrics." }
      responses:
        '200':
          description: Historical metric rows (raw Keyword Planner shape)
          content:
            application/json:
              schema:
                type: object
                properties:
                  customerId: { type: string, description: "The customer the request ran against." }
                  data:
                    type: array
                    items: { type: object, description: "Raw KeywordHistoricalMetricsResult: text, closeVariants, keywordMetrics." }
                  aggregateMetricResults: { type: [object, "null"] }
        '400': { description: "Invalid input, or Google rejected the request — message carries Google's error" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { description: "Per-user Google Ads operations budget or the shared Google quota reached; the message says which and when it resets." }
        '501': { description: Only supported on Google Ads }

  /v1/ads/insights:
    get:
      x-resource-group: "ads"
      operationId: queryAdInsights
      tags: ["Ad Insights"]
      x-platforms: ["meta", "google"]
      summary: Flexible live insights query
      description: |
        Live, flexible insights query. The account's platform picks the contract:

        **Meta (facebook/instagram)**: forwards caller-chosen `fields`, `breakdowns` and `filtering`
        to any Meta insights node and returns Meta's rows verbatim. `objectId` (required) selects the
        node; `level` sets row granularity. Semantic validation is Meta's: an unknown field or invalid
        breakdown combination returns a 400 carrying Meta's message. For long ranges or agency-scale
        accounts prefer the async variant (POST /v1/ads/insights/reports).

        **Google Ads (googleads)**: raw GAQL passthrough. Send any read-only GAQL SELECT via `query`
        (campaign/keyword/search-term/geo/demographic/asset/shopping resources, `change_event`, any
        `segments.*`) and rows come back verbatim (camelCase, counters as strings). Results are paged
        at a fixed 10,000 rows; follow `paging.nextPageToken` with `pageToken`. `customerId` is only
        needed when the connection has several Google Ads accounts. Semantic validation is Google's:
        an invalid query returns a 400 carrying Google's message (note: selecting `segments.date`
        requires a finite date filter).
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant); its platform selects the Meta or Google contract." }
        - { name: objectId, in: query, schema: { type: string }, description: "Meta only (required there): insights node — act_<n>, campaign id, ad set id or ad id." }
        - { name: query, in: query, schema: { type: string, maxLength: 10000 }, description: "Google only (required there): the GAQL SELECT statement to run." }
        - { name: customerId, in: query, schema: { type: string }, description: "Google only: numeric customer id (no dashes) when the connection has several Google Ads accounts." }
        - { name: pageToken, in: query, schema: { type: string }, description: "Google only: cursor from paging.nextPageToken of the previous page." }
        - { name: level, in: query, schema: { type: string, enum: [ad, adset, campaign, account] }, description: Row granularity }
        - { name: fields, in: query, schema: { type: string }, description: "Comma-separated Graph insights fields (e.g. spend,impressions,frequency,website_purchase_roas). Omitted = Meta's default set." }
        - { name: breakdowns, in: query, schema: { type: string }, description: "Comma-separated Graph breakdowns (e.g. age,gender or publisher_platform)." }
        - { name: actionBreakdowns, in: query, schema: { type: string }, description: "Comma-separated Graph action breakdowns. Segments the actions[] arrays in each row." }
        - { name: actionAttributionWindows, in: query, schema: { type: string }, description: "Comma-separated Meta attribution windows. Action values are returned keyed per window." }
        - { name: actionReportTime, in: query, schema: { type: string }, description: "When actions are counted: impression, conversion or mixed." }
        - { name: useUnifiedAttributionSetting, in: query, schema: { type: boolean }, description: "Use the ad sets' own attribution settings for action counting." }
        - { name: filtering, in: query, schema: { type: string }, description: "JSON array of Meta filter objects: [{\"field\", \"operator\", \"value\"}]. Applied server-side by Meta." }
        - { name: datePreset, in: query, schema: { type: string }, description: "Meta date_preset (e.g. last_7d, last_30d, this_month). Mutually exclusive with fromDate/toDate." }
        - { name: fromDate, in: query, schema: { type: string, format: date }, description: "Start of range (YYYY-MM-DD); requires toDate." }
        - { name: toDate, in: query, schema: { type: string, format: date }, description: "End of range (YYYY-MM-DD); requires fromDate." }
        - { name: timeIncrement, in: query, schema: { type: string }, description: "Days per row (1-90), monthly, or all_days." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 25 }, description: Rows per page }
        - { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
      responses:
        '200':
          description: Insight rows (raw platform shape)
          content:
            application/json:
              schema:
                type: object
                properties:
                  objectId: { type: string, description: "Meta responses only." }
                  customerId: { type: string, description: "Google responses only: the customer the query ran against." }
                  fieldMask: { type: [string, "null"], description: "Google responses only: the selected fields echoed by Google." }
                  data:
                    type: array
                    items: { type: object, description: "Raw platform row. Meta: keys are the requested Graph fields plus date_start/date_stop. Google: nested GAQL resources in camelCase, int64 counters as strings." }
                  paging:
                    type: object
                    properties:
                      after: { type: [string, "null"], description: "Meta cursor for the next page; null when exhausted." }
                      nextPageToken: { type: [string, "null"], description: "Google cursor for the next page; null when exhausted." }
        '400': { description: "Invalid input, or the platform rejected the query (unknown field, invalid breakdown combo, malformed GAQL) — message carries the platform's error" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { description: "Platform rate limit reached. For Google this is the per-user operations budget or the shared quota; the message says which and when it resets." }
        '501': { description: Only supported on Meta (facebook/instagram) and Google Ads }

  /v1/ads/insights/reports:
    post:
      x-resource-group: "ads"
      operationId: createAdInsightsReport
      tags: ["Ad Insights"]
      x-platforms: ["meta"]
      summary: Submit an async insights report run
      description: |
        Submits an asynchronous Meta insights report. Same query surface as GET /v1/ads/insights, but
        in the JSON body; Meta processes the report server-side, which is the right choice for long
        ranges or large accounts where the sync query is slow or rate-limited. Returns a `reportRunId`
        to poll via GET /v1/ads/insights/reports/{reportRunId}.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, objectId]
              properties:
                accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant)." }
                objectId: { type: string, description: "Meta insights node: act_<n>, campaign id, ad set id or ad id." }
                level: { type: string, enum: [ad, adset, campaign, account] }
                fields: { type: string, description: "Comma-separated Graph insights fields." }
                breakdowns: { type: string, description: "Comma-separated Graph breakdowns." }
                actionBreakdowns: { type: string, description: "Comma-separated Graph action breakdowns (e.g. action_type,action_destination)." }
                actionAttributionWindows:
                  type: array
                  items: { type: string }
                  description: "Meta attribution windows (e.g. [\"7d_click\", \"1d_view\"]). Action values are returned keyed per window."
                actionReportTime: { type: string, description: "When actions are counted: impression, conversion or mixed." }
                useUnifiedAttributionSetting: { type: boolean, description: "Use the ad sets' own attribution settings for action counting." }
                filtering:
                  type: array
                  description: "Meta filter objects, applied server-side."
                  items:
                    type: object
                    required: [field, operator]
                    properties:
                      field: { type: string }
                      operator: { type: string }
                      value: {}
                datePreset: { type: string, description: "Mutually exclusive with fromDate/toDate." }
                fromDate: { type: string, format: date }
                toDate: { type: string, format: date }
                timeIncrement:
                  oneOf: [{ type: integer, minimum: 1, maximum: 90 }, { type: string, enum: [monthly, all_days] }]
      responses:
        '202':
          description: Report run submitted
          content:
            application/json:
              schema:
                type: object
                properties:
                  reportRunId: { type: string }
                  status: { type: string, example: Job Started }
        '400': { description: "Invalid input, or Meta rejected the report parameters" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { description: Meta rate limit reached }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/insights/reports/{reportRunId}:
    get:
      x-resource-group: "ads"
      operationId: getAdInsightsReport
      tags: ["Ad Insights"]
      x-platforms: ["meta"]
      summary: Poll an async insights report run
      description: |
        Status and results for a report run created via POST /v1/ads/insights/reports. While the job
        runs, returns `status` and `percentCompletion`. Once `status` is "Job Completed" the response
        also carries a `data` page, cursor-paginated via `limit` / `after`.
      security:
        - bearerAuth: []
      parameters:
        - { name: reportRunId, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id used to resolve the Meta token (must be the same connection that created the run)." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 25 } }
        - { name: after, in: query, schema: { type: string } }
      responses:
        '200':
          description: Report run status (plus results when completed)
          content:
            application/json:
              schema:
                type: object
                properties:
                  reportRunId: { type: string }
                  status: { type: string, description: "Meta async_status: Job Not Started, Job Started, Job Running, Job Completed, Job Failed, Job Skipped." }
                  percentCompletion: { type: integer }
                  dateStart: { type: string }
                  dateStop: { type: string }
                  data:
                    type: array
                    items: { type: object }
                    description: "Present only when status is Job Completed."
                  paging:
                    type: object
                    properties:
                      after: { type: [string, "null"] }
        '400': { description: "Invalid input, or the report run is not readable with this account's token" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '429': { description: Meta rate limit reached }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/{adId}/analytics:
    get:
      x-resource-group: "ads"
      operationId: getAdAnalytics
      tags: ["Ad Insights"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      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.

            **LinkedIn** (firmographics): job_title, job_function, seniority, industry,
            company, company_size, country, region. Rows carry the raw pivot `value`
            plus a resolved `name`. LinkedIn serves these aggregated over the whole
            range, delays the data 12-24h, and omits segments with fewer than 3 events.
      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."
                  backfillPending:
                    type: boolean
                    description: "Present and true only on `202` responses: part of the requested date range is still being backfilled from the platform in the background. Retry the same request shortly; it returns 200 once the range is fully ingested."
                  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 }
        '202':
          description: "Part of the requested date range predates the ingested history; a background backfill job has been queued. The body has the same shape as the 200 response, carries the currently-available data, and includes `backfillPending: true`. A `Retry-After` header carries the recommended poll interval in seconds. Allow the job a short time to run (typically 1-3 minutes) and submit the request again; once ingestion completes the same request returns 200 with the full range."
        '400':
          description: "Invalid parameter (e.g. an unknown `breakdowns` dimension). The message lists the offending value(s) and the supported set."
          content:
            application/json:
              schema: { $ref: '#/components/schemas/ErrorResponse' }
        '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:
      x-resource-group: "ads"
      operationId: getAdTrackingTags
      tags: ["Tracking Tags"]
      x-platforms: ["meta"]
      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).

        **Not pixels.** Despite the shared path segment, this endpoint has nothing to do with
        measurement tags. For an ad account's pixels use
        `GET /v1/accounts/{accountId}/tracking-tags?adAccountId=act_...` (Meta Pixels, with `kind`
        and `ownerAdAccountId`) or `GET /v1/accounts/{accountId}/conversion-destinations`.
      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:
      x-resource-group: "ads"
      operationId: updateAdTrackingTags
      tags: ["Tracking Tags"]
      x-platforms: ["meta"]
      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. Meta dynamic macros ({{ad.id}}, {{campaign.id}}, {{placement}}, ...) are sent through unescaped so Meta expands them; every other character is percent-encoded."
                  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:
      x-resource-group: "engagement"
      operationId: getAdComments
      tags: ["Ad Accounts"]
      x-platforms: ["meta"]
      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:
      x-resource-group: "ads"
      operationId: listAdsBusinessCenters
      tags: ["Ad Accounts"]
      x-platforms: ["tiktok"]
      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' }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: TikTok account not found }
        '422': { description: TikTok Ads not connected }

  /v1/ads/activity:
    get:
      x-resource-group: "ads"
      operationId: getAdsActivityLog
      tags: ["Ad Accounts"]
      x-platforms: ["meta"]
      summary: Ad account change / audit log
      description: |-
        Account-level audit log from Meta's `/act_X/activities`: who changed what and when
        (creates, edits, status flips, budget changes...) with Meta's translated event names and
        the structured before/after in `extra_data`. Rows are returned verbatim. Meta has no
        server-side per-object filter on this edge, so `objectId` filters the returned page
        client-side (combine with paging to walk history for one campaign/ad set/ad).
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
        - { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
        - { name: since, in: query, schema: { type: string, format: date }, description: "Start of range (YYYY-MM-DD)." }
        - { name: until, in: query, schema: { type: string, format: date }, description: "End of range (YYYY-MM-DD)." }
        - { name: objectId, in: query, schema: { type: string }, description: "Client-side filter to one Meta object id (campaign, ad set or ad)." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 }, description: Rows per page }
        - { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
      responses:
        '200':
          description: Activity rows (raw Meta shape)
          content:
            application/json:
              schema:
                type: object
                properties:
                  adAccountId: { type: string }
                  data:
                    type: array
                    items: { type: object, description: "Raw Meta activity row: event_type, translated_event_type, event_time, actor_name, object_id, object_name, object_type, application_name, extra_data (JSON string with before/after)." }
                  paging:
                    type: object
                    properties:
                      after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
        '400': { description: "Invalid input, or Meta rejected the query" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/rf-predictions:
    post:
      x-resource-group: "ads"
      operationId: createRfPrediction
      tags: ["Reach and Frequency"]
      x-platforms: ["meta"]
      summary: Create a Reach & Frequency prediction
      description: |-
        Creates an R&F prediction — a QUOTE, nothing is bought and no ad entities are created.
        Provide a date range plus exactly one of `budgetAmount` (Meta predicts reach) or `reach`
        (Meta predicts the budget). The response carries the estimate and its allowed bounds
        (min/max budget and reach). Predictions expire on their own; to buy, reserve one via
        POST /v1/ads/rf-predictions/{predictionId}/reserve and pass the RESERVED id to
        POST /v1/ads/create with `buyingType: "RESERVED"`.

        Reservation campaigns reject automatic placements. Top-level `placements` wins; when it is
        omitted, `targeting.placements` is used; when neither is set, placements default to
        Facebook feed (+ Instagram stream when a linked IG professional account resolves).
        Instagram placements require that IG account.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, adAccountId, startDate, endDate]
              properties:
                accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant)." }
                adAccountId: { type: string, description: "Meta ad account id (act_<n>)." }
                budgetAmount: { type: number, description: "Whole currency units. Exactly one of budgetAmount / reach." }
                reach: { type: integer, description: "Target unique reach. Exactly one of budgetAmount / reach." }
                startDate: { type: string, format: date-time, description: "Campaign window start (must be in the future)." }
                endDate: { type: string, format: date-time }
                frequencyCap: { type: integer, minimum: 1, maximum: 90, description: "Max impressions per person over the window." }
                targeting: { type: object, description: "Canonical camelCase TargetingSpec (same shape as /v1/ads/create's `targeting`). Defaults to countries: [US]." }
                placements: { type: object, description: "Meta placements object (same shape as /v1/ads/create's `placements`)." }
      responses:
        '201':
          description: Prediction created (usually ready within seconds)
          content:
            application/json:
              schema:
                type: object
                properties:
                  adAccountId: { type: string }
                  currency: { type: string }
                  prediction: { $ref: '#/components/schemas/RfPrediction' }
        '400': { description: "Invalid input, or Meta rejected the prediction — message carries Meta's error" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { description: "No Facebook Page resolved for the account" }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/rf-predictions/{predictionId}:
    get:
      x-resource-group: "ads"
      operationId: getRfPrediction
      tags: ["Reach and Frequency"]
      x-platforms: ["meta"]
      summary: Read a Reach & Frequency prediction
      security:
        - bearerAuth: []
      parameters:
        - { name: predictionId, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
        - { name: adAccountId, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Prediction status and estimates
          content:
            application/json:
              schema:
                type: object
                properties:
                  adAccountId: { type: string }
                  currency: { type: string }
                  prediction: { $ref: '#/components/schemas/RfPrediction' }
        '400': { description: Invalid input }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }
    delete:
      x-resource-group: "ads"
      operationId: cancelRfReservation
      tags: ["Reach and Frequency"]
      x-platforms: ["meta"]
      summary: Cancel a Reach & Frequency reservation
      description: Releases a RESERVATION's locked price and inventory. Unreserved predictions expire on their own.
      security:
        - bearerAuth: []
      parameters:
        - { name: predictionId, in: path, required: true, schema: { type: string } }
        - { name: accountId, in: query, required: true, schema: { type: string } }
        - { name: adAccountId, in: query, required: true, schema: { type: string } }
      responses:
        '200':
          description: Reservation cancelled
        '400': { description: "Invalid input, or Meta rejected the cancel" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/rf-predictions/{predictionId}/reserve:
    post:
      x-resource-group: "ads"
      operationId: reserveRfPrediction
      tags: ["Reach and Frequency"]
      x-platforms: ["meta"]
      summary: Reserve a Reach & Frequency prediction
      description: |-
        Locks the quoted price + inventory until the returned `expiresAt` and mints a NEW
        prediction id — pass that RESERVED id (not the original) as `rfPredictionId` on
        POST /v1/ads/create. Release an unused reservation via DELETE.
      security:
        - bearerAuth: []
      parameters:
        - { name: predictionId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, adAccountId]
              properties:
                accountId: { type: string }
                adAccountId: { type: string }
      responses:
        '201':
          description: Reserved; `prediction.predictionId` is the new RESERVED id
          content:
            application/json:
              schema:
                type: object
                properties:
                  adAccountId: { type: string }
                  prediction: { $ref: '#/components/schemas/RfPrediction' }
        '400': { description: "Invalid input, or Meta rejected the reserve" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/studies:
    get:
      x-resource-group: "ads"
      operationId: listAdStudies
      tags: ["Ad Accounts"]
      x-platforms: ["meta"]
      summary: A/B tests and lift studies
      description: |-
        Lists the ad account's A/B tests and lift studies (Meta's `/act_X/ad_studies`), rows
        returned verbatim. The default projection covers id, name, type, timing and cells with
        split percentages; `fields` is a raw-passthrough override.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
        - { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
        - { name: fields, in: query, schema: { type: string }, description: "Comma-separated Graph field override (supports nested {} projections)." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
        - { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
      responses:
        '200':
          description: Ad studies (raw Meta shape)
          content:
            application/json:
              schema:
                type: object
                properties:
                  adAccountId: { type: string }
                  data:
                    type: array
                    items: { type: object, description: "Raw Meta ad study row." }
                  paging:
                    type: object
                    properties:
                      after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
        '400': { description: "Invalid input, or Meta rejected the query" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/businesses:
    get:
      x-resource-group: "ads"
      operationId: listMetaBusinesses
      tags: ["Ad Accounts"]
      x-platforms: ["meta"]
      summary: Businesses list
      description: |-
        Business Manager portfolios the connected Meta user belongs to (Meta's `/me/businesses`),
        rows returned verbatim (id, name, verification_status, created_time). Token-scoped, so no
        `adAccountId` is needed. For TikTok Business Centers use
        `GET /v1/ads/business-centers`.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
        - { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
      responses:
        '200':
          description: Businesses (raw Meta shape)
          content:
            application/json:
              schema:
                type: object
                properties:
                  data:
                    type: array
                    items: { type: object, description: "Raw Meta business row (id, name, verification_status, created_time)." }
                  paging:
                    type: object
                    properties:
                      after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
        '400': { description: "Invalid input, or Meta rejected the query" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/labels:
    get:
      x-resource-group: "ads"
      operationId: listAdLabels
      tags: ["Ad Accounts"]
      x-platforms: ["meta"]
      summary: Ad labels
      description: |-
        Lists the ad account's organizational labels (Meta's `/act_X/adlabels`), rows returned
        verbatim (id, name, created/updated time).
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
        - { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
        - { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
      responses:
        '200':
          description: Ad labels (raw Meta shape)
          content:
            application/json:
              schema:
                type: object
                properties:
                  adAccountId: { type: string }
                  data:
                    type: array
                    items: { type: object, description: "Raw Meta ad label row (id, name, created_time, updated_time)." }
                  paging:
                    type: object
                    properties:
                      after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
        '400': { description: "Invalid input, or Meta rejected the query" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/high-demand-periods:
    get:
      x-resource-group: "ads"
      operationId: listHighDemandPeriods
      tags: ["Ad Accounts"]
      x-platforms: ["meta"]
      summary: High demand periods / budget schedules
      description: |-
        Scheduled budget increases (Meta's budget-scheduling API). The Graph edge lives on the
        campaign and ad-set nodes only, so exactly one of `campaignId` / `adSetId` (platform
        ids) is required. Rows returned verbatim (budget_value, budget_value_type, time window,
        recurrence).
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
        - { name: campaignId, in: query, schema: { type: string }, description: "Platform campaign id. Exactly one of campaignId / adSetId." }
        - { name: adSetId, in: query, schema: { type: string }, description: "Platform ad set id. Exactly one of campaignId / adSetId." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
        - { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
      responses:
        '200':
          description: Budget schedules (raw Meta shape)
          content:
            application/json:
              schema:
                type: object
                properties:
                  objectId: { type: string, description: "The campaign / ad set id the schedules belong to." }
                  data:
                    type: array
                    items: { type: object, description: "Raw Meta high-demand-period row." }
                  paging:
                    type: object
                    properties:
                      after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
        '400': { description: "Invalid input, or Meta rejected the query" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/creatives:
    get:
      x-resource-group: "ads"
      operationId: listAdCreatives
      tags: ["Ad Creatives"]
      x-platforms: ["meta"]
      summary: Creative library
      description: |-
        Lists the ad account's creative library (Meta's `/act_X/adcreatives`), rows returned
        verbatim. The default projection covers id, name, status, object type, thumbnail,
        object_story_spec / asset_feed_spec and url_tags; `fields` is a raw-passthrough
        override. Any creative id here is reusable on the create endpoints via
        `existingCreativeId`.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
        - { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
        - { name: fields, in: query, schema: { type: string }, description: "Comma-separated Graph field override (supports nested {} projections)." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
        - { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
      responses:
        '200':
          description: Creatives (raw Meta shape)
          content:
            application/json:
              schema:
                type: object
                properties:
                  adAccountId: { type: string }
                  data:
                    type: array
                    items: { type: object, description: "Raw Meta creative row." }
                  paging:
                    type: object
                    properties:
                      after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
        '400': { description: "Invalid input, or Meta rejected the query" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }
    post:
      x-resource-group: "ads"
      operationId: createAdCreative
      tags: ["Ad Creatives"]
      x-platforms: ["meta"]
      summary: Create a standalone creative
      description: |-
        Creates a creative in the library WITHOUT an ad, reusable on the create endpoints via
        `existingCreativeId`. Provide exactly one of `imageUrl` (uploaded server-side),
        `imageHash` (from POST /v1/ads/images or the library list), or `carouselCards` (2-10
        hand-built cards). The Page (and linked Instagram account, when present) is resolved
        from `accountId` as the story actor.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, adAccountId, headline, body, linkUrl]
              properties:
                accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token and Page." }
                adAccountId: { type: string, description: "Meta ad account id (act_<n>)." }
                headline: { type: string, maxLength: 255 }
                body: { type: string, description: Primary text }
                description: { type: string, maxLength: 255, description: "Link description below the headline; omitted = Meta scrapes the destination's OG description." }
                callToAction: { type: string, default: LEARN_MORE, description: "CTA type (same whitelist as POST /v1/ads/create)." }
                linkUrl: { type: string, format: uri }
                imageUrl: { type: string, format: uri, description: "Publicly reachable image; uploaded to the account's library server-side." }
                imageHash: { type: string, description: "Existing library image hash (POST /v1/ads/images or GET /v1/ads/images)." }
                carouselCards:
                  type: array
                  minItems: 2
                  maxItems: 10
                  items:
                    type: object
                    required: [imageUrl, linkUrl]
                    properties:
                      imageUrl: { type: string, format: uri }
                      linkUrl: { type: string, format: uri }
                      headline: { type: string, maxLength: 255 }
                      description: { type: string, maxLength: 255 }
                      callToAction: { type: string }
                urlTags: { type: string, description: "Appended to every outbound URL (e.g. utm_source=fb)." }
                creativeFeatures:
                  type: object
                  additionalProperties: { type: string, enum: [OPT_IN, OPT_OUT] }
                  description: 'Advantage+ creative enhancements: partial map of Meta creative feature keys (snake_case) to enroll status, forwarded as degrees_of_freedom_spec.creative_features_spec. Unspecified features default to OPT_OUT.'
      responses:
        '201':
          description: Creative created
          content:
            application/json:
              schema:
                type: object
                properties:
                  adAccountId: { type: string }
                  creativeId: { type: string, description: "Platform creative id, reusable via existingCreativeId." }
        '400': { description: "Invalid input, or Meta rejected the create" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422': { description: No Facebook Page found to act as the story actor }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/creatives/{creativeId}:
    get:
      x-resource-group: "ads"
      operationId: getAdCreative
      tags: ["Ad Creatives"]
      x-platforms: ["meta"]
      summary: Creative details
      description: |-
        One creative's details, verbatim from Meta. `fields` is a raw-passthrough override of
        the default projection.
      security:
        - bearerAuth: []
      parameters:
        - { name: creativeId, in: path, required: true, schema: { type: string }, description: Platform creative id }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
        - { name: fields, in: query, schema: { type: string }, description: "Comma-separated Graph field override (supports nested {} projections)." }
      responses:
        '200':
          description: Creative details
          content:
            application/json:
              schema:
                type: object
                properties:
                  creative: { type: object, description: Raw Meta creative node }
        '400': { description: "Invalid input, or Meta rejected the query" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }
    put:
      x-resource-group: "ads"
      operationId: updateAdCreative
      tags: ["Ad Creatives"]
      x-platforms: ["meta"]
      summary: Rename a creative
      description: |-
        Renames a creative. Creatives are immutable on Meta beyond `name` — for content changes
        create a new creative (POST /v1/ads/creatives) and swap it onto the ad
        (PUT /v1/ads/{adId} with `creative`).
      security:
        - bearerAuth: []
      parameters:
        - { name: creativeId, in: path, required: true, schema: { type: string }, description: Platform creative id }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, name]
              properties:
                accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
                name: { type: string, maxLength: 255 }
      responses:
        '200':
          description: Creative renamed
          content:
            application/json:
              schema:
                type: object
                properties:
                  creativeId: { type: string }
                  name: { type: string }
                  message: { type: string }
        '400': { description: "Invalid input, or Meta rejected the update" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }
    delete:
      x-resource-group: "ads"
      operationId: deleteAdCreative
      tags: ["Ad Creatives"]
      x-platforms: ["meta"]
      summary: Delete a creative
      description: |-
        Deletes a creative from the library. Meta only allows deleting creatives not referenced
        by any ad — otherwise its 400 surfaces verbatim.
      security:
        - bearerAuth: []
      parameters:
        - { name: creativeId, in: path, required: true, schema: { type: string }, description: Platform creative id }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
      responses:
        '200':
          description: Creative deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  creativeId: { type: string }
                  message: { type: string }
        '400': { description: "Invalid input, the creative is in use, or Meta rejected the delete" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/value-rule-sets:
    get:
      x-resource-group: "ads"
      operationId: listValueRuleSets
      tags: ["Ad Accounts"]
      x-platforms: ["meta"]
      summary: List value rule sets
      description: |-
        Lists the ad account's value rule sets (Meta's `/act_X/value_rule_set`). A value rule
        set adjusts the auction bid up or down for audience segments you value differently;
        attach one to an ad set with `valueRuleSetId` on `POST /v1/ads/create` or
        `PUT /v1/ads/ad-sets/{adSetId}`.

        Rows are returned in the same camelCase shape the `PUT` body takes, ids included, so a
        set round-trips 1:1: **the update is a full replace, not a patch**, so you GET, mutate
        and send the whole thing back.

        Limits: 6 rule sets per ad account, 10 rules per set, 4 criteria per rule.

        **Rule order is semantic.** Rules are evaluated in array order and only the FIRST
        matching rule adjusts the bid for an overlapping audience. The order you send is the
        order that is stored and returned.

        Eligibility: value rule sets apply only to ad sets on the `LOWEST_COST_WITHOUT_CAP`
        (auto-bid) or `COST_CAP` bid strategies. Meta rejects the rest server-side.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
        - { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
        - { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page. Meta does not document paging on this edge; `after` comes back null when it omits cursors." }
      responses:
        '200':
          description: Value rule sets
          content:
            application/json:
              schema:
                type: object
                properties:
                  adAccountId: { type: string }
                  data:
                    type: array
                    items: { $ref: '#/components/schemas/ValueRuleSet' }
                  paging:
                    type: object
                    properties:
                      after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted or when Meta omits paging." }
        '400': { description: "Invalid input, or Meta rejected the query. Meta answers a bad rule-set id with GraphMethodException code 100 / subcode 33, which is indistinguishable between not-found, no-permission, and account-not-enabled." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }

    post:
      x-resource-group: "ads"
      operationId: createValueRuleSet
      tags: ["Ad Accounts"]
      x-platforms: ["meta"]
      summary: Create a value rule set
      description: |-
        Creates a value rule set on the ad account (Meta's `POST /act_X/value_rule_set`).
        Attach the returned id to an ad set with `valueRuleSetId` on `POST /v1/ads/create` or
        `PUT /v1/ads/ad-sets/{adSetId}`.

        **Rule order is semantic**: rules are evaluated in array order and only the first
        matching rule adjusts the bid for an overlapping audience.

        `adjustValue` is an unsigned magnitude in percent; the direction lives in `adjustSign`.
        `INCREASE` accepts 1-1000, `DECREASE` accepts 1-90. There is no signed field and 0 is
        out of range.

        `criteriaValueTypes` is positionally paired with `criteriaValues` (same length, same
        order). Every type is the literal `"NONE"` except on `LOCATION`, which uses
        `LOCATION_COUNTRY` / `LOCATION_REGION` / `LOCATION_CITY` / `LOCATION_COMSCORE_MARKET`
        and may mix them within one criterion. Location values are Targeting-Search keys: a
        two-letter country code for `LOCATION_COUNTRY`, a numeric key for the rest.

        `LOCATION_DMA` was replaced by `LOCATION_COMSCORE_MARKET` on 2026-06-22 and rules using
        DMAs are no longer active, so this API rejects it.

        `AUDIENCE_LABEL` values (e.g. `HIGH_VALUE`) are applied to a Custom Audience in Ads
        Manager. There is no API to provision them, so label strings are passed through
        unvalidated and a typo produces a rule that never fires.

        Ads Manager turns a rule set read-only (this API stays editable) when a rule uses more
        than 2 criteria, a custom age range, or the placements `FB_MARKETPLACE`, `FB_SEARCH`,
        `FB_VIDEO` or `IG_EXPLORE`.

        Limits: 6 rule sets per ad account, 10 rules per set, 4 criteria per rule. The
        per-account cap is enforced by Meta, not here.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, adAccountId, name, rules]
              properties:
                accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
                adAccountId: { type: string, description: "Meta ad account id (act_<n>)." }
                name: { type: string, minLength: 1, maxLength: 255 }
                rules:
                  type: array
                  minItems: 1
                  maxItems: 10
                  description: "Evaluated in order; the first matching rule wins."
                  items: { $ref: '#/components/schemas/ValueRule' }
      responses:
        '201':
          description: Value rule set created
          content:
            application/json:
              schema:
                type: object
                properties:
                  adAccountId: { type: string }
                  valueRuleSetId: { type: [string, "null"], description: "The new rule set id. Meta does not document the create response body, so this is null on the (unobserved) case where it omits the id." }
        '400': { description: "Invalid input, or Meta rejected the create (per-account rule-set cap, ineligible criteria, or an account that is not enabled for value rules)" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/value-rule-sets/{valueRuleSetId}:
    get:
      x-resource-group: "ads"
      operationId: getValueRuleSet
      tags: ["Ad Accounts"]
      x-platforms: ["meta"]
      summary: Read a value rule set
      description: |-
        Reads one value rule set including every nested rule id and criterion id. This is step
        one of any edit: `PUT` is a full replace, so you need the ids before you can keep the
        objects you are not changing.

        Meta's own read returns `GENDER` values lowercase (`"male"`) while writes require
        `"MALE"`. Values are passed through untouched, so never case-compare a stored rule
        against a fetched one.
      security:
        - bearerAuth: []
      parameters:
        - { name: valueRuleSetId, in: path, required: true, schema: { type: string }, description: "Platform value rule set id." }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
      responses:
        '200':
          description: Value rule set
          content:
            application/json:
              schema:
                type: object
                properties:
                  valueRuleSet: { $ref: '#/components/schemas/ValueRuleSet' }
        '400': { description: "Invalid input, or Meta rejected the read. A bad id comes back as GraphMethodException code 100 / subcode 33, which cannot be told apart from a permission problem." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }

    put:
      x-resource-group: "ads"
      operationId: updateValueRuleSet
      tags: ["Ad Accounts"]
      x-platforms: ["meta"]
      summary: Replace a value rule set
      description: |-
        **THIS IS A FULL REPLACE, NOT A PATCH.** Meta's update is declarative: the body you
        send becomes the rule set.

        - `GET /v1/ads/value-rule-sets/{valueRuleSetId}` FIRST.
        - Keep a rule or criterion by echoing its `id`.
        - Create one by including the object WITHOUT an `id`.
        - Delete one by OMITTING it from the array. There is no warning and no undo.

        `name` and `rules` are both required for exactly this reason: a partial body would
        silently destroy every rule left out.

        **Rule order is semantic**: the array order you send is the evaluation order, and only
        the first matching rule adjusts the bid for an overlapping audience.

        Existing rule sets created elsewhere may contain `LOCATION_DMA` criteria. Those went
        inert on 2026-06-22 and are rejected here; migrate them to `LOCATION_COMSCORE_MARKET`.
      security:
        - bearerAuth: []
      parameters:
        - { name: valueRuleSetId, in: path, required: true, schema: { type: string }, description: "Platform value rule set id." }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, name, rules]
              properties:
                accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
                name: { type: string, minLength: 1, maxLength: 255, description: "Required: the update replaces the whole set." }
                rules:
                  type: array
                  minItems: 1
                  maxItems: 10
                  description: "The COMPLETE rule list. Omitting a rule deletes it on Meta."
                  items: { $ref: '#/components/schemas/ValueRule' }
      responses:
        '200':
          description: Value rule set replaced
          content:
            application/json:
              schema:
                type: object
                properties:
                  valueRuleSetId: { type: string }
                  name: { type: string }
                  rules:
                    type: array
                    items: { $ref: '#/components/schemas/ValueRule' }
                  message: { type: string }
        '400': { description: "Invalid input, or Meta rejected the update" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }

    delete:
      x-resource-group: "ads"
      operationId: deleteValueRuleSet
      tags: ["Ad Accounts"]
      x-platforms: ["meta"]
      summary: Delete a value rule set
      description: |-
        Deletes the rule set (Meta's `POST /{value-rule-set-id}/delete_rule_set`, a custom
        action edge rather than an HTTP DELETE on its side). Ad sets pointing at it are not
        modified here; detach them first with `valueRulesApplied: false` on
        `PUT /v1/ads/ad-sets/{adSetId}`.
      security:
        - bearerAuth: []
      parameters:
        - { name: valueRuleSetId, in: path, required: true, schema: { type: string }, description: "Platform value rule set id." }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
      responses:
        '200':
          description: Value rule set deleted
          content:
            application/json:
              schema:
                type: object
                properties:
                  valueRuleSetId: { type: string }
                  message: { type: string }
        '400': { description: "Invalid input, or Meta rejected the delete. A bad id comes back as GraphMethodException code 100 / subcode 33, which reads like a permission error rather than a 404." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/accounts/finance:
    get:
      x-resource-group: "ads"
      operationId: getAdAccountFinance
      tags: ["Ad Accounts"]
      x-platforms: ["meta"]
      summary: Ad account finances
      description: |-
        Finances of one Meta ad account: prepaid `balance`, lifetime `amountSpent`, account
        `spendCap` (null = no cap) and the `fundingSource`. Money values are converted from
        Meta's minor units to whole units of `currency`.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
        - { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
      responses:
        '200':
          description: Account finances
          content:
            application/json:
              schema:
                type: object
                properties:
                  adAccountId: { type: string }
                  currency: { type: string, description: "ISO 4217 code all money values are expressed in." }
                  balance: { type: number, description: "Outstanding/prepaid balance in whole currency units." }
                  amountSpent: { type: number, description: "Lifetime amount spent in whole currency units." }
                  spendCap: { type: [number, "null"], description: "Account spend cap; null when none is set." }
                  fundingSource:
                    type: [object, "null"]
                    properties:
                      displayString: { type: string, description: "Human-readable payment method, e.g. 'Mastercard *4985'." }
                      type: { type: integer, description: "Meta funding source type code." }
        '400': { description: "Invalid input, or Meta rejected the query" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/accounts:
    get:
      x-resource-group: "ads"
      operationId: listAdAccounts
      tags: ["Ad Accounts"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      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.

        For Google Ads: responds `429` when Google's API quota is temporarily exhausted
        (instead of an empty list). Retry after a delay.
      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, description: "LinkedIn only. LinkedIn's own ad account status. In practice always `ACTIVE`, because the LinkedIn query filters to active accounts. Meta, Google, TikTok and Pinterest report `accountStatus` instead; X reports `approvalStatus`." }
                        accountStatus:
                          description: |
                            The platform's own account status, forwarded unchanged. No JSON type is
                            declared because the type differs per platform: Meta sends an integer,
                            Google, TikTok and Pinterest send a string. Absent on LinkedIn (reports
                            `status`) and on X (reports `approvalStatus`).

                            If all you need is whether the account can run ads right now, read
                            `selectable` and skip this field. Read this one when you need to tell
                            the states apart, because they call for different responses:

                            - `1` ACTIVE. Running normally.
                            - `2` DISABLED. Disabled by Meta. Read `disableReason` to tell a policy
                              action apart from a billing one; they need very different follow-ups.
                            - `3` UNSETTLED. There is an unpaid balance, but the account still runs
                              ads. Not a ban.
                            - `7` PENDING_RISK_REVIEW. Meta is reviewing the account. Wait for the
                              outcome.
                            - `8` PENDING_SETTLEMENT. Meta blocks new ads until an outstanding
                              balance clears. Settle it and the account runs again.
                            - `9` IN_GRACE_PERIOD. Still running, on a deadline.
                            - `100` PENDING_CLOSURE. Scheduled to close.
                            - `101` CLOSED. Terminal.
                        approvalStatus: { type: string, description: "X only. X's own ad account approval status. Observed values are `ACCEPTED`, `PENDING` and `REJECTED`, but X does not publish the full vocabulary, so treat an unrecognised value as not usable. Other platforms report `accountStatus` or `status` instead." }
                        disableReason: { type: integer, description: "Meta only. Meta's `disable_reason` code, forwarded unchanged. Present when `accountStatus` is `2` (DISABLED) and Meta gives a reason, which is what separates a policy action from a payment problem. Meta does not publish a stable list of values for this field, so none are enumerated here: resolve the code against Meta's own ad account reference. Absent when Meta reports no reason, or when the connected token cannot read the field." }
                        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)." }
                        minimumDailyBudget: { type: number, description: "Meta only. Minimum daily budget for the account, in the account currency's major units. This is the impressions-billed minimum; other billing events have higher minimums. Absent when the connected token cannot read it." }
                        selectable: { type: boolean, description: "Meta and X only. Whether the account can create/run ads now. Absent (treat as true) on other platforms." }
                        unusableReason: { type: [string, "null"], description: "Meta and X only. Human-readable reason when selectable is false; null when selectable." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '422':
          description: Platform ads connection required (TikTok Ads, X Ads) or Instagram missing linked Facebook account
        '429': { $ref: '#/components/responses/RateLimited' }
    patch:
      x-resource-group: "ads"
      operationId: updateAdAccount
      tags: ["Ad Accounts"]
      x-platforms: ["meta"]
      summary: Update ad account settings
      description: |
        Sets the default DSA beneficiary and payor on a Meta ad account (EU DSA, Article 26).
        Set them once and every EU-targeted call to `/v1/ads/create`, `/v1/ads/boost` and
        `/v1/ads/ctwa` on that ad account can omit `dsaBeneficiary`/`dsaPayor`: Meta applies
        the defaults automatically.

        The values are written to the ad account on Meta, the same setting Ads Manager edits.
        Nothing is stored in Zernio, and defaults already set in Ads Manager work identically.
        Zernio never guesses these values for you. Beneficiary and payor are legal disclosures
        shown to EU users, so you must provide the entity names explicitly. Use
        `GET /v1/ads/dsa-recommendations` to offer suggestions in your UI.

        If `defaultDsaPayor` is omitted, the beneficiary is also set as the payor, which
        covers the common case where the same entity benefits from and pays for the ads.
        Read the current values back with `GET /v1/ads/dsa-defaults`.

        Currently supported for Meta accounts only; other platforms return 400.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, adAccountId, defaultDsaBeneficiary]
              properties:
                accountId: { type: string, description: "Social account ID (metaads, or a facebook/instagram posting account)" }
                adAccountId: { type: string, description: "Meta ad account ID (act_...)" }
                defaultDsaBeneficiary: { type: string, maxLength: 100, description: "Legal entity benefiting from ads on this ad account" }
                defaultDsaPayor: { type: string, maxLength: 100, description: "Legal entity paying for ads on this ad account. Defaults to defaultDsaBeneficiary when omitted." }
      responses:
        '200':
          description: DSA defaults updated (re-read from Meta after the write)
          content:
            application/json:
              schema:
                type: object
                properties:
                  adAccountId: { type: string }
                  dsaDefaults:
                    type: object
                    properties:
                      beneficiary: { type: string }
                      payor: { type: string }
        '400':
          description: Unsupported platform (non-Meta account) or invalid adAccountId
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: Social account not found

  /v1/ads/dsa-defaults:
    get:
      x-resource-group: "ads"
      operationId: getDsaDefaults
      tags: ["Ad Accounts"]
      x-platforms: ["meta"]
      summary: Get ad account DSA defaults
      description: |
        Returns the default DSA beneficiary and payor currently set on a Meta ad account,
        whether they were set via `PATCH /v1/ads/accounts` or in Meta Ads Manager. Fields
        are omitted when no default is configured. Meta accounts only.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Social account ID (metaads, or a facebook/instagram posting account)" }
        - { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account ID (act_...)" }
      responses:
        '200':
          description: Current DSA defaults (empty object when none are set)
          content:
            application/json:
              schema:
                type: object
                properties:
                  adAccountId: { type: string }
                  dsaDefaults:
                    type: object
                    properties:
                      beneficiary: { type: string }
                      payor: { type: string }
        '400':
          description: Non-Meta adAccountId
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: Social account not found

  /v1/ads/dsa-recommendations:
    get:
      x-resource-group: "ads"
      operationId: getDsaRecommendations
      tags: ["Ad Accounts"]
      x-platforms: ["meta"]
      summary: List DSA beneficiary/payor suggestions
      description: |
        Returns Meta's suggested beneficiary/payor names for an ad account, derived by Meta
        from the account's recent activity. Useful for prefilling `dsaBeneficiary`/`dsaPayor`
        inputs, or the defaults sent to `PATCH /v1/ads/accounts`, in your own UI.

        Meta returns a single flat list. Entries are not labeled as beneficiary or payor,
        and since these are legal disclosures Zernio never applies them automatically: let
        your user pick the right entity. The list may be empty for accounts with little
        activity. Meta accounts only.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Social account ID (metaads, or a facebook/instagram posting account)" }
        - { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account ID (act_...)" }
      responses:
        '200':
          description: Suggested DSA strings (may be empty when Meta has no recommendations)
          content:
            application/json:
              schema:
                type: object
                properties:
                  adAccountId: { type: string }
                  recommendations:
                    type: array
                    items: { type: string }
        '400':
          description: Non-Meta adAccountId
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404':
          description: Social account not found

  /v1/ads/boost:
    post:
      x-resource-group: "ads"
      operationId: boostPost
      tags: ["Ad Campaigns"]
      x-platforms: ["meta"]
      summary: Boost post as ad
      description: |
        Creates a paid ad from an existing published post, keeping the post's
        engagement. By default it provisions the whole hierarchy (campaign, ad
        set, ad).

        **Attach shape (Meta).** Send `adSetId` to put the ad under an EXISTING
        ad set instead, so that ad set keeps its learning phase. It then owns
        `budget`, `schedule` and `targeting`, and sending any of those alongside
        `adSetId` is a 400 rather than a silent drop. `budget` is required only
        without `adSetId`.

        `instagramAccountId`, `destinationType` and `adSetId` are Meta-only and
        return 400 on other platforms.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, adAccountId, name, goal]
              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." }
                adSetId: { type: string, description: "Meta only. Attach the boosted post to this existing ad set instead of creating a campaign. The ad set then owns budget, schedule and targeting; sending those too is a 400." }
                budget:
                  type: object
                  description: "Required unless adSetId is set."
                  required: [amount, type]
                  properties:
                    amount: { type: number, description: "Minimum varies: TikTok=$20, Pinterest=$5, others=$1" }
                    type: { type: string, enum: [daily, lifetime] }
                instagramAccountId: { type: string, description: "Meta only. Instagram identity the ad runs AS (creative.instagram_user_id), overriding the account linked to the Page. Live-verified against a Page-post creative." }
                destinationType: { type: string, enum: [INSTAGRAM_PROFILE, WEBSITE, ON_AD, MESSENGER, WHATSAPP], description: "Meta only. Ad-set destination_type — where the click LANDS, as opposed to instagramAccountId which is who the ad runs as. Lead ads force ON_AD and ignore this." }
                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
                  description: |
                    Same geo/demographic fields as the `TargetingSpec` used by /v1/ads/create.
                    Geo keys (`regions`/`cities`/`zips`/`metros`) resolve via
                    GET /v1/ads/targeting/search?dimension=geo. City radius and lat/lng
                    `customLocations` are Meta-only and preserve the boosted post's
                    social proof (the ad references the existing post).
                  properties:
                    ageMin: { type: integer, minimum: 13, maximum: 65 }
                    ageMax: { type: integer, minimum: 13, maximum: 65 }
                    gender: { type: string, enum: [all, male, female], description: "Applied on Meta, TikTok and Pinterest. Ignored on Google, LinkedIn and X." }
                    languages: { type: array, items: { type: string }, description: "Meta locale ids (numeric), passed through as given." }
                    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." }
                    regions:
                      type: array
                      description: "Region/state targeting. `key` 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` + `distanceUnit` extend beyond the city limits (both set together, Meta only)."
                      items:
                        type: object
                        required: [key]
                        properties:
                          key: { type: string }
                          name: { type: string }
                          radius: { type: number, description: "Requires distanceUnit. Meta enforces a minimum city radius (~17 km / 10 mi); smaller values resolve to a 0-size audience and the ad fails at launch. For a tighter catchment use customLocations (lat/lng)." }
                          distanceUnit: { type: string, enum: [mile, kilometer] }
                    zips:
                      type: array
                      description: "Postal/ZIP targeting. `key` is the platform's postal location ID (e.g. Meta `US:94304`)."
                      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). No geo `key` lookup needed."
                      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, description: "Optional label, sent to Meta as `address_string`." }
                    interests:
                      type: array
                      description: "Interest objects from /v1/ads/interests. Each must include id and name."
                      items:
                        type: object
                        required: [id, name]
                        properties:
                          id: { type: string }
                          name: { type: string }
                    advantage_audience: { type: integer, enum: [0, 1], description: "Meta only. 0 = disabled (default), 1 = enabled." }
                rawTargeting:
                  type: object
                  additionalProperties: true
                  description: |
                    Meta only. A Meta-native targeting spec (e.g.
                    `{ "geo_locations": { "cities": [{ "key": "...", "radius": 15, "distance_unit": "kilometer" }] } }`).
                    Sent alone it is forwarded unchanged. Use for advanced fields the structured
                    object does not expose (flexible_spec, excluded audiences, business places,
                    user_os, wireless_carrier).

                    Can be combined with `targeting`: rawTargeting is the BASE layer and the
                    built camelCase spec is merged on top, key by key (camelCase wins on
                    collision). The merge goes one level deep inside `geo_locations` and
                    `excluded_geo_locations` (built sub-keys win; raw-only sub-keys such as
                    `location_types` survive). Array values (`flexible_spec`, ...) are replaced
                    as a whole key, never element-merged.

                    When `rawTargeting` is present the `advantage_audience: 0` default that
                    Zernio normally applies is no longer emitted, so it cannot clobber a
                    `targeting_automation` sent in the raw spec. Meta requires
                    `targeting_automation` on ad set creation, so include it in the raw spec,
                    or send `targeting.advantage_audience` (0 or 1), which is merged over raw
                    as `targeting_automation`.
                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).
                platformSpecificData:
                  description: |
                    Platform-specific options. The platform is derived from `accountId`;
                    sending options for a different platform returns a 400. LinkedIn
                    (campaign bidding and delivery controls) is the only platform with
                    options today.
                  oneOf:
                    - $ref: '#/components/schemas/LinkedInAdsPlatformData'
                tracking:
                  type: object
                  description: "Meta only. Tracking specs (pixel, URL tags)."
                  properties:
                    pixelId: { type: string }
                    urlTags:
                      type: array
                      description: 'URL parameters appended to the ad link, rendered as `key=value` pairs joined with `&`. Meta dynamic macros ({{ad.id}}, {{campaign.id}}, {{placement}}, ...) are sent through unescaped so Meta expands them; every other character is percent-encoded.'
                      items:
                        type: object
                        properties:
                          key: { type: string }
                          value: { type: string }
                specialAdCategories:
                  type: array
                  description: "Meta only. Required for housing, employment, credit, or political ads."
                  items: { type: string, enum: [HOUSING, EMPLOYMENT, CREDIT, FINANCIAL_PRODUCTS_SERVICES, ISSUES_ELECTIONS_POLITICS, ONLINE_GAMBLING_AND_GAMING] }
                specialAdCategoryCountry:
                  type: array
                  description: 'Meta (metaads) only. 2-letter ISO country codes the special ad category applies to. Requires specialAdCategories to be set (400 otherwise).'
                  items: { type: string }
                linkUrl:
                  type: string
                  format: uri
                  description: |
                    Destination URL for the CTA button. Send it together with `callToAction`.

                    **Meta**: adds a top-level `call_to_action` to the post-reference creative.
                    This is what gives a `traffic` boost a clickable destination without
                    replacing the creative and losing the post's social proof. Ignored when
                    `leadGenFormId` is set, which supplies its own destination. Live-verified
                    against a Page-post creative.

                    **TikTok**: maps to `landing_page_url` on the Spark Ad creative
                    (`AdcreateCreatives.landing_page_url`); Spark Ads have no clickable
                    destination without it.

                    Ignored on LinkedIn / Pinterest / X / Google, which infer the destination
                    from the boosted post.
                callToAction:
                  type: string
                  description: |
                    CTA button label. Send it together with `linkUrl` — a CTA without a
                    destination produces a button that goes nowhere, so sending one alone is a 400.

                    **Meta**: validated against the Meta CTA enum (same values as
                    POST /v1/ads/create), e.g. `LEARN_MORE`, `SHOP_NOW`, `SIGN_UP`.

                    **TikTok**: pass-through to `call_to_action` on the Spark Ad creative; the
                    platform validates the value. See TikTok's "Enumeration - Call-to-Action".
                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: |
                    Legal entity that benefits from the ad. Required when targeting EU users
                    (EU DSA, Article 26). Optional if the ad account has a default beneficiary:
                    set it once via `PATCH /v1/ads/accounts` or in Meta Ads Manager, and Meta
                    fills it in whenever the field is omitted.
                dsaPayor:
                  type: string
                  maxLength: 100
                  description: |
                    Legal entity that pays for the ad. Can differ from `dsaBeneficiary`
                    (for example, an agency paying for a client's ads). Same rules as
                    `dsaBeneficiary`: required for EU targeting unless the ad account has
                    a default payor.
                optimizationGoal:
                  type: string
                  description: |
                    Meta only. Explicit ad-set `optimization_goal` override. When omitted,
                    defaults to the value derived from `goal`. The value must be compatible
                    with the objective Meta derives from `goal`, not with the objective used
                    by `POST /v1/ads/create` for the same `goal` name: boost maps `goal:
                    "engagement"` to objective `OUTCOME_AWARENESS`, which accepts
                    `REACH`, `IMPRESSIONS`, `AD_RECALL_LIFT`, or THRUPLAY-class values, and
                    rejects `POST_ENGAGEMENT` (that value is only valid under
                    `OUTCOME_ENGAGEMENT`, which create uses for the same goal name).
      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:
      x-resource-group: "ads"
      operationId: createStandaloneAd
      tags: ["Ad Campaigns"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      summary: Create standalone ad
      description: >-
        Creates a paid ad with custom creative across Meta, Google Ads,
        Pinterest, TikTok, X/Twitter, LinkedIn, and OpenAI Ads (ChatGPT Ads).
        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 an attach shape via adSetId that
        adds one new ad to an existing ad set, inheriting its budget,
        targeting, and schedule (Meta, TikTok, and LinkedIn; on LinkedIn
        adSetId is the existing Campaign id, and the budget, schedule,
        targeting and bidding fields must be omitted). 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:
        - $ref: '#/components/parameters/IdempotencyKeyHeader'
      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). Meta dynamic macros ({{ad.id}}, {{campaign.id}}, {{placement}}, ...) are sent through unescaped so Meta expands them; every other character is percent-encoded."
                      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, job_applicants]
                  description: |
                    Required on legacy and multi-creative shapes; the attach shape inherits it from the ad set. Available goals vary by platform.

                    **Meta**
                    - `conversions`: OUTCOME_SALES. Requires `promotedObject.pixelId` and `promotedObject.customEventType` with a commerce event such as PURCHASE or START_TRIAL, or `promotedObject.customConversionId` to optimise against a Custom Conversion, or `customEventType: OTHER` + `customEventStr` to optimise against a pixel custom event.
                    - `lead_conversion`: OUTCOME_LEADS optimizing website pixel leads. Same pixel and event fields, but with a leads-class event such as LEAD, SUBMIT_APPLICATION, SCHEDULE or CONTACT (or `promotedObject.customConversionId` to optimise against a Custom Conversion instead). Meta gates conversion events by objective, so leads-class events are rejected under `conversions`.
                    - `lead_generation`: OUTCOME_LEADS with instant forms. Requires `leadGenFormId`. `promotedObject.pageId` is optional and auto-filled from the connected Page.
                    - `app_promotion`: requires `promotedObject.applicationId` and `promotedObject.objectStoreUrl`.
                    - `catalog_sales`: Advantage+ catalog ads, for example vehicle inventory. Requires `promotedObject.productSetId`, `promotedObject.pixelId` and `promotedObject.customEventType`. Builds a catalog TEMPLATE creative from the copy fields, which may carry template tags like {{product.name}} or {{vehicle.make}}. No imageUrl or 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 or placementAssets.

                    **TikTok**
                    - `conversions`: website-conversion ad group. Requires `promotedObject.pixelId`, your TikTok Pixel ID. Accepts an optional `promotedObject.customEventType` with a TikTok optimization_event code your pixel tracks (newer pixels use e.g. SHOPPING for purchase events; legacy pixels use ON_WEB_ORDER, INITIATE_ORDER, ON_WEB_REGISTER or FORM). To inherit pixel and event from an existing ad group, pass `adSetId` instead.

                    **LinkedIn**
                    - `engagement`, `traffic`, `awareness` and `video_views` create standalone Direct Sponsored Content ads. `traffic` requires `linkUrl`; `video_views` requires `video`.
                    - `job_applicants` requires a `platformSpecificData.jobs` creative.
                    - For `lead_generation` or `conversions` on LinkedIn, or to promote an existing post, use POST /v1/ads/boost.

                    **OpenAI Ads**
                    - Only `traffic`, `awareness`, and `conversions` are supported (other goals return 400). Maps to OpenAI's `bidding_type` (clicks, impressions, conversions respectively). `conversions` requires an active conversion event setting on the account; create a tracking tag with `defaultEventType` via the tracking-tags API (`POST /v1/accounts/{accountId}/tracking-tags`), or configure a conversion event in OpenAI Ads Manager, or the request returns 422.
                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." }
                billingEvent: { type: string, description: "Meta only. Explicit ad-set `billing_event`. Defaults to `IMPRESSIONS`. Forwarded verbatim to Meta, which validates compatibility with the optimization goal." }
                buyingType: { type: string, enum: [AUCTION, RESERVED], description: "Meta only. RESERVED = Reach & Frequency: requires `rfPredictionId` (a RESERVED prediction from /v1/ads/rf-predictions + /reserve). Budget, schedule and pricing come from the reservation, so budgetAmount/budgetType are not required and bid fields are ignored. Only the plain single-ad shape (no creatives[], adSetId, existingCampaignId or dynamicCreative)." }
                rfPredictionId: { type: string, description: "Meta only. The RESERVED prediction id the R&F ad set runs on (reserving mints a new id — pass that one). Requires buyingType RESERVED." }
                creativeFeatures:
                  type: object
                  additionalProperties: { type: string, enum: [OPT_IN, OPT_OUT] }
                  description: 'Meta only. Advantage+ creative enhancements: a partial map of Meta creative feature keys (snake_case, e.g. enhance_cta, image_brightness_and_contrast, text_optimizations) to enroll status, forwarded as degrees_of_freedom_spec.creative_features_spec. Meta validates the keys; unspecified features default to OPT_OUT. The legacy standard_enhancements bundle is deprecated by Meta and rejected.'
                validateOnly:
                  type: boolean
                  description: 'Meta only, single standalone shape only (no creatives[], adSetId, or RESERVED). Dry-run: each node runs Meta''s execution_options validate_only and NOTHING is created or persisted. Children need real parents, so a fresh tree validates the campaign + creative (the ad set needs its campaign to exist — pass existingCampaignId to validate it too; the ad itself is never validatable pre-create). A Meta validation failure returns the 400 verbatim; success returns 200 with per-node results instead of an ad.'
                budgetAmount: { type: number, description: "Budget in WHOLE currency units (USD: 50 = $50.00), NOT cents — Meta's own Marketing API takes this same number in minor units, so it is an easy and expensive mix-up. Required on legacy + multi-creative shapes. Inherited on attach. OpenAI Ads requires a $1 minimum (its budget is lifetime-only, see budgetType)." }
                budgetType: { type: string, enum: [daily, lifetime], description: "Required on legacy + multi-creative shapes. Inherited on attach. OpenAI Ads accepts lifetime only (no daily-budget concept on the platform); sending daily returns 422. OpenAI Ads lifetime budgets require `endDate` to give the lifetime cap a spend window." }
                status:
                  type: string
                  enum: [ACTIVE, PAUSED]
                  description: "Meta and TikTok. Publish state of the created entities. Omitted or ACTIVE publishes live (default, back-compat); PAUSED creates them paused and skips activation, so you can review before they spend. On TikTok the whole campaign > ad group > ad hierarchy stays paused."
                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, LinkedIn, and OpenAI Ads 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, OpenAI=50 (min 3). On LinkedIn this is the ad's headline (the bold text on the creative); for traffic ads it's the link card title. On OpenAI Ads this is the chat card's 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). On OpenAI Ads this is the chat card's body text. Max: Google=90, Pinterest=500, OpenAI=100." }
                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, ADD_TO_CART, APPLY_NOW, BOOK_NOW, BUY_TICKETS, DONATE, DONATE_NOW, GET_DIRECTIONS, GET_SHOWTIMES, LISTEN_NOW, ORDER_NOW, PLAY_GAME, REQUEST_TIME, SEE_MENU, START_ORDER, INSTALL_MOBILE_APP, USE_APP, 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). On LinkedIn, `imageUrl` + `linkUrl` publishes an ARTICLE-content creative; this is LinkedIn's article ad format, with the image as thumbnail and `longHeadline` as description. Required for OpenAI Ads (the chat card's target_url)." }
                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` (or `videoUrls`) 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). Required for OpenAI Ads (uploaded as the chat card's image; OpenAI has no video ad format)." }
                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."
                  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. Provide either `url` or `id`."}
                    id: { type: string, description: "Meta only. Reuse a video ALREADY uploaded to this ad account instead of re-uploading the file: pass the `videoId` returned by a previous create. Wins over `url`, so N ads that differ only in copy share one upload (`existingCreativeId` only covers the identical-copy case). Provide either `url` or `id`."}
                    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, ADD_TO_CART, APPLY_NOW, BOOK_NOW, BUY_TICKETS, DONATE, DONATE_NOW, GET_DIRECTIONS, GET_SHOWTIMES, LISTEN_NOW, ORDER_NOW, PLAY_GAME, REQUEST_TIME, SEE_MENU, START_ORDER, INSTALL_MOBILE_APP, USE_APP] }
                adSetId:
                  type: string
                  description: |
                    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), TikTok, and
                    LinkedIn. On TikTok the `adSetId` is the ad group ID; the
                    new ad inherits the ad group's bid + budget + targeting.
                    On LinkedIn the `adSetId` is the LinkedIn Campaign ID
                    (numeric); we attach a new Creative to that Campaign, so
                    the Campaign's `platformSpecificData` bidding, targeting,
                    budget and schedule are inherited (passing those fields
                    returns 400).
                existingCampaignId:
                  type: string
                  description: |
                    Meta + LinkedIn. On Meta: 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 LinkedIn: create a new Campaign (and its
                    Creative) under this EXISTING CampaignGroup. On failure
                    only the entities we authored are cleaned up; the
                    pre-existing parent 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." }
                targeting:
                  description: |
                    Nested targeting object — the same TargetingSpec shape as `POST /v1/ads/boost`,
                    `POST /v1/ads/targeting/reach-estimate`, and `saved_targeting` audiences. Merged
                    UNDER the flat inline targeting fields below: `savedTargetingId` < `targeting` <
                    flat fields (a flat field present on the body replaces the nested value entirely).
                    Both forms are equivalent; use whichever your integration already builds.
                  allOf:
                    - $ref: '#/components/schemas/TargetingSpec'
                countries: { type: array, items: { type: string }, description: "ISO 3166-1 alpha-2 country codes (e.g. ['NL']). Defaults to ['US'] when no other geo targeting (flat or nested `targeting`) is provided. (LinkedIn and OpenAI Ads currently honour country-level targeting only; any other targeting field returns 400 for OpenAI Ads.)" }
                cities:
                  type: array
                  description: |
                    City-level geo targeting (Meta and TikTok). Each city is targeted by the platform's opaque `key` (the city ID) which can be looked up via `GET /v1/ads/targeting/search?dimension=geo&q=<name>&countryCode=<ISO>`. Optional `radius` + `distance_unit` (Meta only) 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).

                    On Meta, 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. On TikTok, keys are numeric location ids and can be sent without `countries`.
                  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. Meta enforces a minimum city radius (~17 km / 10 mi); smaller values resolve to a 0-size audience and the ad fails at launch. For a tighter catchment use customLocations (lat/lng)." }
                      distance_unit: { type: string, enum: [mile, kilometer], description: "Unit for radius. Required if radius is set." }
                regions:
                  type: array
                  description: |
                    Region-level (state/province) geo targeting (Meta and TikTok). Each region is targeted by the platform's opaque `key` (the region ID) which can be looked up via `GET /v1/ads/targeting/search?dimension=geo&q=<name>&countryCode=<ISO>`.
                  items:
                    type: object
                    required: [key]
                    properties:
                      key: { type: string, description: "Platform 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 (Meta and TikTok). `key` is the platform's metro ID from /v1/ads/targeting/search?dimension=geo&geoType=metro (TikTok metros appear as type `metro`, e.g. the New York DMA)."
                  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 restricting the audience by language. On Meta, ISO 639-1 codes (e.g. ['en'], ['de']); a bare code targets all regional variants (\"en\" = all English), or use a region-qualified code for a specific one (\"en_GB\", \"pt_BR\", \"zh_TW\"). Unknown codes are rejected. Other ad platforms use their own language-code systems." }
                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, whatsapp] }
                      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 (snake_case: `geo_locations`, `age_min`,
                    `excluded_custom_audiences`, `flexible_spec`, `targeting_automation`, `user_os`,
                    `wireless_carrier`, business places, etc.) — exactly the shape `GET /v1/ads/{adId}` returns for
                    external ads. Sent alone it reaches the ad set VERBATIM (the clone-a-campaign's-targeting-exactly
                    path). Meta validates and surfaces any errors.

                    Can be combined with the camelCase targeting fields (countries/regions/cities/interests/ageMin/...,
                    `targeting`, `savedTargetingId`, `audienceId`): rawTargeting is the BASE layer and the built
                    camelCase spec is merged on top, key by key, with the camelCase side winning on collision (the
                    camelCase precedence chain stays `savedTargetingId` < `targeting` < flat fields). The merge goes
                    one level deep inside `geo_locations` and `excluded_geo_locations`: built sub-keys win, raw-only
                    sub-keys such as `location_types` survive alongside built `countries`. Array values
                    (`flexible_spec`, ...) are replaced as a WHOLE key when the camelCase spec builds them, never
                    element-merged. When rawTargeting is present the defaults the camelCase builder normally injects
                    (US geo, `targeting_automation.advantage_audience: 0`) are suppressed, so raw's values are not
                    clobbered — include `targeting_automation` in the raw spec (or send `advantageAudience`) as Meta
                    requires it on create. 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, FINANCIAL_PRODUCTS_SERVICES, ISSUES_ELECTIONS_POLITICS, ONLINE_GAMBLING_AND_GAMING] }
                specialAdCategoryCountry:
                  type: array
                  description: |
                    Meta (metaads) only. 2-letter ISO country codes the special ad category applies to. Requires
                    specialAdCategories to be set (400 otherwise). Ignored when joining an existing campaign via
                    existingCampaignId (the existing campaign's category/country already governs it).
                  items: { type: string }
                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. Exactly ONE of `imageUrls` /
                    `videoUrls` is required (Meta allows one ad format per asset feed; sending both →
                    400). Meta limits: ≤10 images or ≤10 videos, ≤5 bodies / titles / descriptions.
                  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. Mutually exclusive with `videoUrls`."
                    videoUrls:
                      type: array
                      minItems: 1
                      maxItems: 10
                      items: { type: string, format: uri }
                      description: "Pool of video URLs (1-10). Uploaded to the ad account and referenced by video id in the asset feed. No thumbnails are needed: Meta auto-generates a poster per video. Mutually exclusive with `imageUrls`; `adFormat` defaults to SINGLE_VIDEO."
                    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, ADD_TO_CART, APPLY_NOW, BOOK_NOW, BUY_TICKETS, DONATE, DONATE_NOW, GET_DIRECTIONS, GET_SHOWTIMES, LISTEN_NOW, ORDER_NOW, PLAY_GAME, REQUEST_TIME, SEE_MENU, START_ORDER, INSTALL_MOBILE_APP, USE_APP, 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, SINGLE_VIDEO]
                      description: "Asset-feed ad format. Must match the pool: SINGLE_IMAGE / CAROUSEL_IMAGE require `imageUrls`, SINGLE_VIDEO requires `videoUrls` (400 otherwise). Defaults to SINGLE_IMAGE with `imageUrls`, SINGLE_VIDEO with `videoUrls`."
                carouselCards:
                  type: array
                  minItems: 2
                  maxItems: 10
                  description: |
                    Meta only. Hand-built carousel: 2-10 authored cards in DETERMINISTIC order, mapped to
                    the creative's `link_data.child_attachments`. Unlike `dynamicCreative`,
                    you control the card order and per-card copy/link. Requires top-level `body`,
                    `linkUrl` and `callToAction`.
                    Mutually exclusive with `imageUrl`/`video`, `creatives[]`, `dynamicCreative`,
                    `placementAssets`, `existingCreativeId`, `adSetId`, `leadGenFormId` and goal
                    `catalog_sales`.
                  items:
                    type: object
                    required: [imageUrl]
                    properties:
                      imageUrl: { type: string, format: uri, description: "Card image; uploaded to the ad account and referenced by hash." }
                      linkUrl: { type: string, format: uri, description: "Card destination URL. Defaults to the top-level linkUrl." }
                      headline: { type: string, maxLength: 255, description: "Card headline, shown below the card image." }
                      description: { type: string, maxLength: 255, description: "Card description, shown under the headline." }
                      callToAction: { type: string, description: "Card CTA override. Defaults to the top-level callToAction; same enum." }
                defaultLocale:
                  type: string
                  description: "Meta only. Language the top-level copy is written in (e.g. `en`, `pt_BR`), used by the `translations` default rule. Defaults to `en`. Meta rejects a language asset feed whose default rule carries no locales of its own. Must NOT also appear as an entry in `translations`."
                translations:
                  type: array
                  minItems: 1
                  maxItems: 10
                  description: |
                    Meta only. Multi-language ads (Dynamic Language Optimization): ONE ad carrying
                    per-locale copy and, optionally, per-locale media — the "Languages" toggle in Ads
                    Manager. Keeps social proof (likes/comments/shares) on a SINGLE post instead of
                    splitting it across one ad per language.

                    The ad's top-level copy is the DEFAULT shown to every locale you do NOT list,
                    and it counts as one of the language variants.

                    IMPORTANT, and the opposite of what you might expect: text does NOT inherit.
                    Every entry must carry its own `headline`, `body` AND `description`, and all of
                    them must be DISTINCT from each other and from the ad's top-level copy. Meta
                    deduplicates identical strings inside the asset feed, so two locales sharing a
                    string collapse into one asset and the create fails with a misleading "Too few
                    ... texts provided in asset creation" (subcode 1885817) that names a field which
                    is actually present. We validate this before calling Meta and return a 400
                    naming the offending locale and field. `description` is therefore effectively
                    required on the ad whenever `translations` is present, even though it is
                    optional otherwise.

                    Do NOT list `defaultLocale` inside `translations`: Meta rejects the duplicate
                    with "The language asset feed includes an unsupported targeting field"
                    (subcode 1885985).

                    Media DOES inherit and is uploaded once when shared, and `linkUrl` inherits
                    too: each locale may name its own landing page and unlisted locales fall back
                    to the ad's top-level `linkUrl`. Note that Meta enforces
                    Dynamic Creative image dimensions on language feeds, so an `imageUrl` that
                    works on a normal ad may be rejected with "The following images have invalid
                    dimensions for Dynamic Creative" (subcode 1885558). Video is not affected.

                    Mutually exclusive with `dynamicCreative`, `placementAssets`, `carouselCards` and
                    `existingCreativeId` — Meta allows one `asset_feed_spec` shape per creative.
                  items:
                    type: object
                    required: [locale, headline, body, description]
                    properties:
                      locale: { type: string, description: "Language code, resolved to Meta's numeric locale id. Bare codes target the '(All)' umbrella (`es` = every Spanish variant); region-qualified codes target the variant (`pt_BR`, `en_GB`)." }
                      headline: { type: string, maxLength: 255, description: "Headline for this language. REQUIRED, and must differ from every other locale and from the ad's top-level headline." }
                      body: { type: string, description: "Primary text for this language. REQUIRED, and must differ from every other locale and from the ad's top-level body." }
                      description: { type: string, maxLength: 255, description: "Link description for this language. REQUIRED, and must differ from every other locale and from the ad's top-level description." }
                      linkUrl: { type: string, format: uri, description: "Destination URL for this language. Inherits the ad's top-level `linkUrl` when omitted, and requires it to be present (400 otherwise): the top-level URL is the destination for every locale you did not override. Unlike text, identical URLs across locales are fine (they share one asset)." }
                      imageUrl: { type: string, format: uri, description: "Image for this language. Inherits the ad's `imageUrl` when omitted. The feed is all-image OR all-video." }
                      videoUrl: { type: string, format: uri, description: "Video for this language. Inherits the ad's `video.url` when omitted. The feed is all-image OR all-video." }
                      thumbnailUrl: { type: string, format: uri, description: "Poster frame for this language's video." }
                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, whatsapp] }
                              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: "Restrict the audience by gender. 'male' targets men only, 'female' targets women only, 'all' (default) targets everyone. Applied on Meta, TikTok and Pinterest. Ignored on Google, LinkedIn and X." }
                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.
                valueRuleSetId:
                  type: string
                  pattern: '^\d+$'
                  description: |
                    Meta only (facebook, instagram; other platforms return 400). Value rule set
                    to attach to the new ad set, from `/v1/ads/value-rule-sets`. Attachment is
                    driven by this id, so `valueRulesApplied` is optional alongside it.

                    Rejected with 400 in `adSetId` attach mode: that shape inherits the existing
                    ad set's attachment, so the field would be silently ignored. Use
                    `PUT /v1/ads/ad-sets/{adSetId}` there instead.

                    Ignored (stripped before the ad-set create) when `buyingType` is `RESERVED`:
                    value rules only apply to auction ad sets on `LOWEST_COST_WITHOUT_CAP` or
                    `COST_CAP`, and a Reach & Frequency reservation has no auction bid strategy.

                    Read back with `GET /v1/ads/ad-sets/{adSetId}?fields=value_rule_set_id`; the
                    attachment is not mirrored onto Zernio's ad documents.
                valueRulesApplied:
                  type: boolean
                  description: |
                    Meta only (facebook, instagram; other platforms return 400). Optional when
                    attaching, and requires `valueRuleSetId`. `false` is REJECTED here with 400:
                    a newly created ad set has nothing to detach, so detaching lives on
                    `PUT /v1/ads/ad-sets/{adSetId}`.
                platformSpecificData:
                  description: |
                    Platform-specific options. The platform is derived from `accountId`;
                    sending options for a different platform returns a 400. LinkedIn
                    (campaign bidding and delivery controls) is the only platform with
                    options today.
                  oneOf:
                    - $ref: '#/components/schemas/LinkedInAdsPlatformData'
                dsaBeneficiary:
                  type: string
                  maxLength: 100
                  description: |
                    Legal entity that benefits from the ad. Required when targeting EU users
                    (EU DSA, Article 26). Optional if the ad account has a default beneficiary:
                    set it once via `PATCH /v1/ads/accounts` or in Meta Ads Manager, and Meta
                    fills it in whenever the field is omitted.
                dsaPayor:
                  type: string
                  maxLength: 100
                  description: |
                    Legal entity that pays for the ad. Can differ from `dsaBeneficiary`
                    (for example, an agency paying for a client's ads). Same rules as
                    `dsaBeneficiary`: required for EU targeting unless the ad account has
                    a default payor.
                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` / `lead_conversion` (OFFSITE_CONVERSIONS): requires `pixelId` + `customEventType`, or `customConversionId` when optimising against a Custom Conversion (the conversion carries its own event definition). For a pixel CUSTOM event (one you named yourself in CAPI/Events Manager), send `customEventType: OTHER` + `customEventStr` with the event name.
                      - `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`.
                        To discover the pixels an ad account can use, call
                        `GET /v1/accounts/{accountId}/tracking-tags?adAccountId=act_...` (each entry
                        carries `kind` and `ownerAdAccountId`), or
                        `GET /v1/accounts/{accountId}/conversion-destinations`. Note this is a
                        different resource from `GET /v1/ads/{adId}/tracking-tags`, which reads an
                        ad's click-URL params (`url_tags`), not pixels.
                    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), OR the exact event name shown in TikTok Events Manager
                        (auto-resolved to its code). Must be one of the event types your TikTok
                        Pixel tracks; custom events are not optimizable. Current taxonomy:
                        `SHOPPING` (Purchase), `ON_WEB_CART` (Add to Cart), `INITIATE_ORDER`
                        (Initiate Checkout), `FORM` (Lead), `ON_WEB_REGISTER` (Complete
                        Registration), `ON_WEB_DETAIL` (View Content). `ON_WEB_ORDER` is
                        deprecated. On rejection the error lists the event types your pixel
                        actually tracks. Optional for `goal: conversions`.
                    customEventStr:
                      type: string
                      description: |
                        Meta only. Pixel custom-event name to optimise against (Meta's
                        `custom_event_str`), exactly as it appears in Events Manager and in your
                        CAPI payloads (case-sensitive, not uppercased). Requires
                        `customEventType: OTHER`, and `OTHER` requires this field (400 either way).
                        The same as picking a custom event in Ads Manager's conversion-event
                        dropdown. For rule-based Custom Conversions use `customConversionId`
                        instead.
                    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.
                    offlineConversionDataSetId:
                      type: string
                      description: 'Meta only. Offline event set (dataset) to optimise toward. Post-merger these are datasets: the id is the dataset id (for pixel-backed datasets, the pixel id).'
                    whatsappPhoneNumber:
                      type: string
                      description: 'Meta only. WhatsApp number on messaging-destination ad sets.'
                  additionalProperties: false
      responses:
        '200':
          description: 'validateOnly dry-run passed — nothing was created'
          content:
            application/json:
              schema:
                type: object
                properties:
                  validateOnly: { type: boolean, description: 'Always true in a validate-only response.' }
                  results:
                    type: array
                    items:
                      type: object
                      properties:
                        node: { type: string, enum: [campaign, adSet, creative, ad] }
                        status: { type: string, enum: [validated, skipped] }
                        reason: { type: string, description: "Why the node could not be validated (only on skipped)." }
                  message: { type: string }
        '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, non-Meta platform used with creatives[] / adSetId, or a Meta validateOnly validation failure (verbatim)'
        '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:
      x-resource-group: "contacts"
      operationId: listLeads
      tags: ["Lead Gen"]
      x-platforms: ["meta", "linkedin"]
      summary: List submitted leads
      description: >
        Returns submitted Lead Gen leads for your team, newest-first, with
        keyset pagination on `cursor`. For Meta (default) leads are served from
        the persisted cache, ingested in real time from the `leadgen` webhook.
        When `accountId` is a LinkedIn ads account, leads are fetched live from
        LinkedIn's `leadFormResponses` (LinkedIn has no webhook and enforces
        90-day retention, so nothing is persisted) and `adAccountId` is required.
        Reading LinkedIn responses needs the `r_marketing_leadgen_automation`
        permission; accounts connected before it was added must reconnect.
        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. LinkedIn ads accounts switch to the live fetch. }
        - { name: adAccountId, in: query, schema: { type: string }, description: "LinkedIn only: the LinkedIn ad account id whose responses to read (owner-scoped finder)." }
        - { 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 timestamp. }
        - { name: cursor, in: query, schema: { type: string }, description: "Keyset cursor from a previous response's pagination.cursor (Meta: AdLead id; LinkedIn: numeric start offset)." }
      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"] }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Ads add-on required. }

  /v1/ads/lead-forms:
    get:
      x-resource-group: "ads"
      operationId: listLeadForms
      tags: ["Lead Gen"]
      x-platforms: ["meta", "linkedin"]
      summary: List lead forms
      description: >
        Lists the Lead Gen forms owned by the account. Meta: forms on the
        connected Facebook Page. LinkedIn: forms owned by the ad account's
        Company Page — pass `adAccountId` (LinkedIn forms are org-owned).
        Requires the Ads add-on.
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: Connected facebook or linkedin ads account id. }
        - { name: adAccountId, in: query, schema: { type: string }, description: "LinkedIn only: the LinkedIn ad account id (used to resolve the owning organization). Required for LinkedIn." }
        - { 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"] } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Ads add-on required. }
    post:
      x-resource-group: "ads"
      operationId: createLeadForm
      tags: ["Lead Gen"]
      x-platforms: ["meta", "linkedin"]
      summary: Create a lead form
      description: >
        Creates a Lead Gen form. The form content goes inside
        `platformSpecificData` for both platforms (the shape is selected by the
        accountId's platform). Meta: created on the connected Facebook Page
        (POST /{page-id}/leadgen_forms); the old top-level Meta fields
        (questions, thankYou*, contextCard, …) are DEPRECATED but still
        accepted while platformSpecificData is absent — mixing both shapes is
        a 400. LinkedIn: created on the ad account's Company Page. NOT
        idempotent — a retry creates a second form. Meta prefilled question
        types (EMAIL, PHONE, FULL_NAME, …) must omit label/key; CUSTOM
        questions require both. LinkedIn exposes only free-text and
        multiple-choice questions via API (prefilled-from-profile fields are
        Campaign Manager UI-only). Requires the Ads add-on.
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, name, privacyPolicyUrl]
              properties:
                accountId: { type: string }
                name: { type: string, maxLength: 200 }
                questions:
                  type: array
                  minItems: 1
                  deprecated: true
                  description: "Deprecated (Meta legacy shape): use platformSpecificData.questions."
                  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, deprecated: true, description: "Deprecated: use platformSpecificData.privacyPolicyLinkText." }
                followUpActionUrl: { type: string, format: uri, deprecated: true, description: "Deprecated: use platformSpecificData.followUpActionUrl." }
                locale: { type: string, example: EN_US, deprecated: true, description: "Deprecated: use platformSpecificData.locale." }
                thankYouTitle: { type: string, deprecated: true, description: "Deprecated: use platformSpecificData.thankYouTitle." }
                thankYouBody: { type: string, deprecated: true, description: "Deprecated: use platformSpecificData.thankYouBody." }
                thankYouButtonText: { type: string, deprecated: true, description: "Deprecated: use platformSpecificData.thankYouButtonText." }
                thankYouButtonType: { type: string, example: VIEW_WEBSITE, deprecated: true, description: "Deprecated: use platformSpecificData.thankYouButtonType." }
                thankYouWebsiteUrl: { type: string, format: uri, deprecated: true, description: "Deprecated: use platformSpecificData.thankYouWebsiteUrl." }
                isOptimizedForQuality: { type: boolean, deprecated: true, description: "Deprecated: use platformSpecificData.isOptimizedForQuality." }
                platformSpecificData:
                  description: "Form content; the shape is selected by the accountId's platform. Unknown fields are a 400 (strict-parsed)."
                  oneOf:
                    - type: object
                      title: MetaLeadFormPlatformData
                      required: [questions]
                      properties:
                        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 }
                        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, description: 'Set true for a higher-intent form (adds a review step before submit).' }
                        blockDisplayForNonTargetedViewer: { type: boolean }
                        questionPageCustomHeadline: { type: string }
                        contextCard:
                          type: object
                          properties:
                            title: { type: string }
                            content: { type: array, items: { type: string } }
                            style: { type: string, enum: [LIST_STYLE, PARAGRAPH_STYLE] }
                            buttonText: { type: string }
                            coverPhoto: { type: string }
                    - type: object
                      title: LinkedInLeadFormPlatformData
                      required: [adAccountId, headline, description, questions]
                      properties:
                        adAccountId: { type: string, description: "LinkedIn ad account id (resolves the owning organization)." }
                        headline: { type: string, maxLength: 255 }
                        description: { type: string, maxLength: 1500 }
                        state: { type: string, enum: [DRAFT, PUBLISHED], description: "Defaults to DRAFT." }
                        locale: { type: object, properties: { country: { type: string }, language: { type: string } } }
                        consents:
                          type: array
                          items:
                            type: object
                            required: [description]
                            properties:
                              description: { type: string }
                              required: { type: boolean, description: "Whether the viewer must tick this consent to submit. Defaults to false.", default: false }
                        questions:
                          type: array
                          minItems: 1
                          items:
                            oneOf:
                              - type: object
                                required: [kind, name, question]
                                properties:
                                  kind: { type: string, enum: [text] }
                                  name: { type: string }
                                  question: { type: string }
                                  required: { type: boolean }
                                  responseEditable: { type: boolean }
                                  maxResponseLength: { type: integer, description: "Defaults to 300 on LinkedIn's side." }
                              - type: object
                                required: [kind, name, question, choices]
                                properties:
                                  kind: { type: string, enum: [multipleChoice] }
                                  name: { type: string }
                                  question: { type: string }
                                  required: { type: boolean }
                                  responseEditable: { type: boolean }
                                  choices:
                                    type: array
                                    minItems: 1
                                    items:
                                      type: object
                                      required: [id, text]
                                      properties:
                                        id: { type: integer }
                                        text: { type: string }
      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 } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Ads add-on required. }
        '422': { description: "Meta rejected the lead form with error code 3. The Facebook Page normally needs a Page admin to accept the Lead Ads Terms of Service." }

  /v1/ads/lead-forms/{formId}:
    get:
      x-resource-group: "ads"
      operationId: getLeadForm
      tags: ["Lead Gen"]
      x-platforms: ["meta", "linkedin"]
      summary: Get a lead form
      parameters:
        - { name: formId, in: path, required: true, schema: { type: string }, description: "Numeric form id (Meta leadgen_form id or LinkedIn leadForm id)." }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Connected facebook or linkedin ads account id (selects the platform)." }
      responses:
        '200':
          description: Form metadata.
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string }, form: { type: object } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
    delete:
      x-resource-group: "ads"
      operationId: archiveLeadForm
      tags: ["Lead Gen"]
      x-platforms: ["meta", "linkedin"]
      summary: Archive a lead form
      description: "Neither platform hard-deletes a form; this archives it (Meta status=ARCHIVED; LinkedIn state=ARCHIVED via PARTIAL_UPDATE)."
      parameters:
        - { name: formId, in: path, required: true, schema: { type: string }, description: "Numeric form id (Meta leadgen_form id or LinkedIn leadForm id)." }
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Connected facebook or linkedin ads account id (selects the platform)." }
      responses:
        '200':
          description: Archived.
          content:
            application/json:
              schema: { type: object, properties: { status: { type: string }, formId: { type: string }, archived: { type: boolean } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/ads/lead-forms/{formId}/leads:
    get:
      x-resource-group: "contacts"
      operationId: listFormLeads
      tags: ["Lead Gen"]
      x-platforms: ["meta"]
      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"] } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/ads/lead-forms/{formId}/test-leads:
    post:
      x-resource-group: "contacts"
      operationId: createTestLead
      tags: ["Lead Gen"]
      x-platforms: ["meta"]
      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 } } } } }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }

  /v1/ads/images:
    post:
      x-resource-group: "ads"
      operationId: uploadAdImage
      tags: ["Ad Creatives"]
      x-platforms: ["meta"]
      summary: Upload an ad image from base64
      description: |-
        Uploads raw image bytes to the Meta ad account's image library — for callers whose
        creatives aren't hosted at a public URL. Returns the image `hash` (Meta's identifier for
        the asset) and the Meta-hosted `url`, which can be used directly as `imageUrl` on the
        create endpoints. Max 30 MB decoded.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, adAccountId, imageBase64]
              properties:
                accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
                adAccountId: { type: string, description: "Meta ad account id (act_<n>)." }
                imageBase64: { type: string, description: "Raw base64 image bytes, or a full data URL (the data:image/...;base64, prefix is stripped)." }
                filename: { type: string, description: "Optional filename shown in Meta's image library. Defaults to ad_image.jpg." }
      responses:
        '201':
          description: Image uploaded
          content:
            application/json:
              schema:
                type: object
                properties:
                  adAccountId: { type: string }
                  image:
                    type: object
                    properties:
                      hash: { type: string, description: "Meta image hash, reusable wherever image_hash is accepted." }
                      url: { type: string, description: "Meta-hosted image URL; usable as imageUrl on the create endpoints." }
        '400': { description: "Invalid input, or Meta rejected the image" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }

    get:
      x-resource-group: "ads"
      operationId: listAdImages
      tags: ["Ad Creatives"]
      x-platforms: ["meta"]
      summary: Ad image library
      description: |-
        Lists the ad account's image library (Meta's `/act_X/adimages`), rows returned verbatim.
        The default projection covers hash, url, name, dimensions and status; `fields` is a
        raw-passthrough override. Any `hash` here is reusable wherever Meta accepts
        `image_hash` (e.g. `imageHash` on POST /v1/ads/creatives).
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
        - { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
        - { name: fields, in: query, schema: { type: string }, description: "Comma-separated Graph field override (supports nested {} projections)." }
        - { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
        - { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
      responses:
        '200':
          description: Ad images (raw Meta shape)
          content:
            application/json:
              schema:
                type: object
                properties:
                  adAccountId: { type: string }
                  data:
                    type: array
                    items: { type: object, description: "Raw Meta ad image row (hash, url, name, width, height, status)." }
                  paging:
                    type: object
                    properties:
                      after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
        '400': { description: "Invalid input, or Meta rejected the query" }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '501': { description: Only supported on Meta (facebook/instagram) }

  /v1/ads/interests:
    get:
      x-resource-group: "ads"
      operationId: searchAdInterests
      deprecated: true
      tags: ["Ad Targeting"]
      x-platforms: ["meta"]
      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:
      x-resource-group: "ads"
      operationId: searchAdTargeting
      tags: ["Ad Targeting"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest"]
      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.

        TikTok geo searches return every matching level in one list (`type` is
        `country`, `region`, `city`, `district`, or `metro` for DMA areas) —
        `geoType` is not applied. Results are scoped to the advertiser's targetable
        markets, and every id is usable in `regions`/`cities`/`metros` keys on
        `POST /v1/ads/create`.

        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:
      x-resource-group: "ads"
      operationId: estimateAdReach
      tags: ["Ad Targeting"]
      x-platforms: ["meta"]
      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/targeting/bid-pricing:
    post:
      x-resource-group: "ads"
      operationId: getLinkedInBidPricing
      tags: ["Ad Targeting"]
      x-platforms: ["linkedin"]
      summary: Suggested bid and budget bounds
      description: |
        LinkedIn-only. Returns the suggested bid and bid limits for a targeting
        spec, plus the daily-budget bounds LinkedIn will accept. Use it before
        creating a campaign to pick a bid inside the allowed range and warn the
        user if their daily budget is below the minimum. Wraps LinkedIn's
        `adBudgetPricing` finder.

        Non-LinkedIn accounts return `available: false` so clients can hide the
        pricing UI without treating it as a failure.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, adAccountId, spec]
              properties:
                accountId: { type: string, description: "Zernio social account ID (LinkedIn)." }
                adAccountId: { type: string, description: "LinkedIn ad account ID (numeric)." }
                spec: { allOf: [{ $ref: '#/components/schemas/TargetingSpec' }], description: "Same targeting spec used by POST /v1/ads/create." }
                campaignType: { type: string, enum: [TEXT_AD, SPONSORED_UPDATES, SPONSORED_INMAILS], description: "Defaults to SPONSORED_UPDATES." }
                bidType: { type: string, enum: [CPM, CPC, CPV], description: "Defaults to CPM." }
                matchType: { type: string, enum: [EXACT, AUDIENCE_EXPANDED], description: "Defaults to EXACT." }
                currency: { type: string, minLength: 3, maxLength: 3, description: "ISO 4217, defaults to USD." }
                objectiveType: { type: string, description: "LinkedIn objectiveType, e.g. WEBSITE_VISIT, LEAD_GENERATION, VIDEO_VIEW." }
                optimizationTargetType: { type: string, description: "LinkedIn optimizationTargetType, e.g. MAX_CLICK, MAX_IMPRESSION." }
                dailyBudget: { type: number, description: "Optional daily budget in whole account-currency units. LinkedIn refines the suggested bid to this budget." }
      responses:
        '200':
          description: Pricing insights
          content:
            application/json:
              schema:
                type: object
                required: [available]
                properties:
                  available: { type: boolean }
                  pricing:
                    type: [object, "null"]
                    description: LinkedIn's adBudgetPricing element. Null when LinkedIn has no data for the combination.
                    properties:
                      bidLimits:
                        type: object
                        properties:
                          min: { $ref: '#/components/schemas/MoneyAmount' }
                          max: { $ref: '#/components/schemas/MoneyAmount' }
                      suggestedBid:
                        type: object
                        properties:
                          min:     { $ref: '#/components/schemas/MoneyAmount' }
                          default: { $ref: '#/components/schemas/MoneyAmount' }
                          max:     { $ref: '#/components/schemas/MoneyAmount' }
                      dailyBudgetLimits:
                        type: object
                        properties:
                          min:     { $ref: '#/components/schemas/MoneyAmount' }
                          default: { $ref: '#/components/schemas/MoneyAmount' }
                          max:     { $ref: '#/components/schemas/MoneyAmount' }
        '400': { description: "Invalid targeting or unsupported objective/optimization/bid combination." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: "Ads access required." }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/ads/targeting/supply-forecast:
    post:
      x-resource-group: "ads"
      operationId: getLinkedInSupplyForecast
      tags: ["Ad Targeting"]
      x-platforms: ["linkedin"]
      summary: Impressions, clicks and spend forecast
      description: |
        LinkedIn-only. Forecasted impressions, clicks, spend and ~20 other
        metrics for a targeting spec over a time range. Wraps LinkedIn's
        `adSupplyForecasts` finder.

        Each returned series carries a `metricType` (IMPRESSION, CLICK, SPENDING,
        MAX_POTENTIAL_BUDGET, COST_PER_MILLION_IMPRESSIONS, ...) and a
        `granularity` (DAILY, SEVEN_DAY, THIRTY_DAY, CUSTOM). LinkedIn caps the
        daily spending forecast at 1.2x the daily budget and returns 0 once the
        total budget is exhausted.

        Non-LinkedIn accounts return `available: false`.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [accountId, adAccountId, spec, timeRangeStart, timeRangeEnd]
              properties:
                accountId: { type: string }
                adAccountId: { type: string }
                spec: { allOf: [{ $ref: '#/components/schemas/TargetingSpec' }] }
                campaignType: { type: string, enum: [SPONSORED_UPDATES, SPONSORED_INMAILS, DYNAMIC], description: "Defaults to SPONSORED_UPDATES." }
                timeRangeStart: { type: integer, description: "Unix ms. Must be in the future." }
                timeRangeEnd: { type: integer, description: "Unix ms. Must be after start and within LinkedIn's max horizon." }
                objectiveType: { type: string }
                optimizationTarget: { type: string, description: "When set, the forecast assumes auto-bidding. When unset, competingBid is required." }
                dailyBudget: { type: number, description: "Either dailyBudget or totalBudget is required." }
                totalBudget: { type: number }
                currency: { type: string, minLength: 3, maxLength: 3, description: "ISO 4217, defaults to USD." }
                competingBid:
                  type: object
                  required: [bidType, amount]
                  description: "Required for manual-bid forecasts (when optimizationTarget is not set)."
                  properties:
                    bidType: { type: string, enum: [CPM, CPC, CPV] }
                    amount: { type: number }
                enableAudienceNetwork: { type: boolean, description: "Defaults to false. Required true for connectedTelevisionOnly." }
                enableAudienceExpansion: { type: boolean, description: "Defaults to false." }
                connectedTelevisionOnly: { type: boolean, description: "Defaults to false." }
      responses:
        '200':
          description: Forecast series
          content:
            application/json:
              schema:
                type: object
                required: [available]
                properties:
                  available: { type: boolean }
                  forecast:
                    type: array
                    items:
                      type: object
                      properties:
                        metricType:  { type: string }
                        granularity: { type: string, enum: [DAILY, SEVEN_DAY, THIRTY_DAY, CUSTOM] }
                        timeSeries:
                          type: array
                          items:
                            type: object
                            properties:
                              timestamp: { type: integer }
                              value:     { type: number }
                              adForecastRange:
                                type: object
                                properties:
                                  lowEnd:  { type: number }
                                  highEnd: { type: number }
        '400': { description: "Invalid targeting, missing budget, or LinkedIn forecast validation error (e.g. END_DATE_MAX_HORIZON_FOR_FORECAST)." }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: "Ads access required." }
        '404': { $ref: '#/components/responses/NotFound' }

  /v1/ads/catalogs:
    get:
      x-resource-group: "ads"
      operationId: listAdCatalogs
      tags: ["Ad Creatives"]
      x-platforms: ["meta"]
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '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:
      x-resource-group: "ads"
      operationId: listAdCatalogProductSets
      tags: ["Ad Creatives"]
      x-platforms: ["meta"]
      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 }
        '400': { $ref: '#/components/responses/BadRequest' }
        '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:
      x-resource-group: "ads"
      operationId: listAdAudiences
      tags: ["Ad Audiences"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      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, company_list, engagement, meta_engagement, website, website_retargeting, lookalike, saved_targeting] }, description: "Filter to one audience type. `saved_targeting` returns stored TargetingSpec audiences; 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"] }
                        accountId: { type: string, description: "Social account the audience was created against. Returned for saved_targeting items." }
                        platformAudienceId: { type: string }
                        name: { type: string }
                        description: { type: string }
                        type: { type: string, enum: [customer_list, company_list, engagement, meta_engagement, website, website_retargeting, 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:
      x-resource-group: "ads"
      operationId: createAdAudience
      tags: ["Ad Audiences"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      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, company_list, engagement, meta_engagement, website, website_retargeting, lookalike] }
                    matchRules:
                      type: array
                      minItems: 1
                      maxItems: 50
                      description: |
                        Required for website_retargeting audiences (LinkedIn only).
                        Each rule is a URL pattern; a member who visits any
                        matching page enters the segment. Needs the LinkedIn
                        Insight Tag installed on the customer's site — the
                        segment only starts filling once the tag reports visits.

                        The response's `platformAudienceId` is the LinkedIn
                        adSegment id, valid for downstream use.
                        These segments appear in GET /v1/ads/audiences with 
                        `type: website_retargeting` once LinkedIn has finished building them.
                      items:
                        type: object
                        additionalProperties: false
                        required: [matchType, matchValue]
                        properties:
                          matchType: { type: string, enum: [EXACT, STARTS_WITH, CONTAINS, ENDS_WITH] }
                          matchValue: { type: string }
                    sourceType:
                      type: string
                      enum: [VIDEO_ADS, LEAD_GEN_FORMS, ORGANIZATION_PAGES, EVENT_PAGES, SINGLE_IMAGE_ADS]
                      description: |
                        Required for engagement audiences (LinkedIn only): what
                        members engaged with — a video/leadgen/single-image ad
                        campaign, a Company Page or an Event page.
                    trigger:
                      type: string
                      description: |
                        Required for engagement audiences. The action, validated
                        by LinkedIn against `sourceType`. Common values:
                        VIDEO_ADS FIRST_QUARTILE / MIDPOINT / THIRD_QUARTILE /
                        FULL_COMPLETE; LEAD_GEN_FORMS VIEW_FORM /
                        LEAD_FORM_SUBMIT; ORGANIZATION_PAGES VIEW / CTA_CLICK;
                        EVENT_PAGES RSVPED / VIDEO_VIEWED / ENGAGEMENT / CLICK.
                    lookbackDays:
                      type: integer
                      enum: [30, 60, 90, 180, 365]
                      description: Required for engagement audiences. Rolling window.
                    engagementSources:
                      type: array
                      minItems: 1
                      maxItems: 50
                      description: |
                        Required for engagement audiences. Campaign URNs for the
                        ad source types, organization URNs for pages and events.
                        LinkedIn creates one rule per source, all sharing the
                        same trigger and lookbackDays.
                      items: { type: string }
                    companies:
                      type: array
                      minItems: 1
                      maxItems: 300000
                      description: |
                        Required for company_list audiences (LinkedIn only): plain-text
                        company rows for account targeting. Each row needs at least one
                        identifier. LinkedIn recommends 1,000+ companies for a usable
                        match rate and takes up to 48h to process the list.
                      items:
                        type: object
                        additionalProperties: false
                        properties:
                          name: { type: string, maxLength: 255 }
                          domain: { type: string, maxLength: 100 }
                          website: { type: string, maxLength: 100 }
                          linkedinPageUrl: { type: string }
                    pixelId: { type: string, description: Required for website audiences }
                    retentionDays: { type: integer, minimum: 1, maximum: 365, description: "Required for website (max 180) and meta_engagement (max 365) audiences." }
                    engagementSource:
                      type: string
                      enum: [page, instagram, video]
                      description: |
                        Required for meta_engagement audiences (Meta only): what people
                        engaged with. `page` = a Facebook Page, `instagram` = an IG
                        professional account, `video` = a video. The source object must be
                        eligible for engagement audiences or Meta rejects with subcode
                        1713151 ("Invalid Event Name"), surfaced verbatim.
                    sourceId: { type: string, description: "Required for meta_engagement: the Page / IG account / video id." }
                    event:
                      type: string
                      description: |
                        meta_engagement only. The engagement event; defaults per source
                        (page → page_engaged, instagram → ig_business_profile_all,
                        video → video_watched). Ignored when `rule` is provided.
                    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: "Optional raw Meta rule, forwarded verbatim: pixel event rule for website audiences, or the engagement rule for meta_engagement (overrides the built rule, e.g. for event/canvas/lead-form sources)." }
                    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:
      x-resource-group: "ads"
      operationId: getAdAudience
      tags: ["Ad Audiences"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      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' }
    put:
      x-resource-group: "ads"
      operationId: updateAdAudience
      tags: ["Ad Audiences"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      summary: Update an audience
      description: |
        Update an audience. `saved_targeting` audiences accept `name`, `description`, and `spec`
        (full replacement, no merge, Zernio-only, no platform call). Platform audiences
        (uploaded/website/lookalike) accept `name` and `description` only, updated on the
        platform first and then mirrored locally; their rules are immutable, so `spec` returns
        400 for them. Platform audience updates are Meta-only for now (other platforms return
        501). Ads already created from a saved_targeting audience are unaffected, they snapshot
        the targeting at creation.
      security:
        - bearerAuth: []
      parameters:
        - { name: audienceId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              description: At least one field is required.
              properties:
                name: { type: string, maxLength: 255 }
                description: { type: string }
                spec:
                  allOf: [{ $ref: '#/components/schemas/TargetingSpec' }]
                  description: Full replacement for the stored targeting spec.
      responses:
        '200':
          description: Audience updated
          content:
            application/json:
              schema:
                type: object
                properties:
                  audience: { type: object }
                  message: { type: string }
        '400':
          description: 'Invalid body (no fields provided, malformed spec, or spec on a platform audience)'
        '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: The audience has no platform counterpart to update
        '501':
          description: Platform audience updates are only supported on Meta
    delete:
      x-resource-group: "ads"
      operationId: deleteAdAudience
      tags: ["Ad Audiences"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      summary: Delete custom audience
      description: Deletes the audience from both the platform and the local database. `saved_targeting` audiences exist only on Zernio, so only the local record is removed.
      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:
      x-resource-group: "ads"
      operationId: addUsersToAdAudience
      tags: ["Ad Audiences"]
      x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
      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 (malformed audienceId, 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:
      x-resource-group: "ads"
      operationId: getConversionsQuality
      tags: [Conversions]
      x-platforms: ["meta"]
      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." }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '405': { description: "Platform does not expose Event Match Quality (non-Meta)." }
  /v1/ads/conversions:
    post:
      x-resource-group: "ads"
      operationId: sendConversions
      tags: [Conversions]
      x-platforms: ["meta", "google", "tiktok", "linkedin"]
      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
        - OpenAI Ads (`openaiads`) via its Conversions API (a separate host, `bzr.openai.com`)

        `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`
        - OpenAI Ads: pixel wire id (numeric `pixel_id`, distinct from the internal pixel id), as returned by `GET /v1/accounts/{accountId}/conversion-destinations`

        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.

        OpenAI Ads notes: requires a tracking tag (pixel) to already exist on the account — returns 422
        with code `TRACKING_TAG_REQUIRED` if `POST /v1/accounts/{accountId}/tracking-tags` hasn't been
        called yet.

        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. OpenAI Ads caps at 1000 per request; larger submissions are
        split into 1000-event chunks, each all-or-nothing (a malformed event fails every event in
        that chunk, not the whole request).

        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.
        - OpenAI Ads: a fixed subset of standard names (Purchase, Lead, AddToCart, ViewContent, InitiateCheckout, CompleteRegistration, Subscribe, StartTrial, Schedule) maps 1:1 onto OpenAI's own event-type enum; any other standard name or custom string is sent as `type: custom` with the name preserved.
      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, tiktokads, or openaiads).
                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. For OpenAI Ads, the
                    pixel wire id.
                events:
                  type: array
                  minItems: 1
                  items: { $ref: '#/components/schemas/ConversionEvent' }
                testCode:
                  type: string
                  description: Meta `test_event_code` passthrough. Ignored by Google, LinkedIn, and OpenAI Ads.
                consent:
                  type: object
                  description: |
                    Batch-level user consent. Required by Google for EEA/UK
                    events under the Feb 2026 restrictions. On Meta, any
                    DENIED flag enables Limited Data Use on every event in
                    the batch (data_processing_options ["LDU"] with
                    geolocation, country 0 / state 0); GRANTED or absent
                    consent sends events with Meta's default processing.
                    Ignored by 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. For OpenAI Ads, each 1000-event chunk is
            all-or-nothing, same as Meta.
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [metaads, googleads, linkedinads, tiktokads, openaiads] }
                  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)
                      and OpenAI Ads (no trace ID surfaced).
        '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.
        '422':
          description: 'OpenAI Ads only: no tracking tag (pixel) exists yet for this account. Code `TRACKING_TAG_REQUIRED` — create one via `POST /v1/accounts/{accountId}/tracking-tags` first.'
        '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:
      x-resource-group: "ads"
      operationId: adjustConversions
      tags: [Conversions]
      x-platforms: ["meta"]
      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:
      x-resource-group: "ads"
      operationId: listConversionDestinations
      tags: [Conversions]
      x-platforms: ["meta", "google", "tiktok", "linkedin"]
      summary: List conversion destinations
      description: |
        Returns the list of pixels (Meta), conversion actions (Google),
        conversion rules (LinkedIn), or pixels (OpenAI Ads) 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 and OpenAI Ads, `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, tiktokads, or openaiads).
      responses:
        '200':
          description: Destinations listed
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [metaads, googleads, linkedinads, tiktokads, openaiads] }
                  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. OpenAI Ads: pixel wire
                            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:
      x-resource-group: "ads"
      operationId: createConversionDestination
      tags: [Conversions]
      x-platforms: ["meta", "google", "tiktok", "linkedin"]
      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
        and OpenAI Ads pixels are created via their own tracking-tags flow
        instead (`POST /v1/accounts/{accountId}/tracking-tags`); this endpoint
        returns 405 for both.

        **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:
      x-resource-group: "ads"
      operationId: getConversionDestination
      tags: [Conversions]
      x-platforms: ["meta", "google", "tiktok", "linkedin"]
      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:
      x-resource-group: "ads"
      operationId: updateConversionDestination
      tags: [Conversions]
      x-platforms: ["meta", "google", "tiktok", "linkedin"]
      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:
      x-resource-group: "ads"
      operationId: deleteConversionDestination
      tags: [Conversions]
      x-platforms: ["meta", "google", "tiktok", "linkedin"]
      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, or accountId is not a valid id.' }
        '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:
      x-resource-group: "ads"
      operationId: listConversionAssociations
      tags: [Conversions]
      x-platforms: ["meta", "google", "tiktok", "linkedin"]
      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:
      x-resource-group: "ads"
      operationId: addConversionAssociations
      tags: [Conversions]
      x-platforms: ["meta", "google", "tiktok", "linkedin"]
      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:
      x-resource-group: "ads"
      operationId: removeConversionAssociations
      tags: [Conversions]
      x-platforms: ["meta", "google", "tiktok", "linkedin"]
      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`,
            campaignIds exceeds 100 entries per request, or `accountId` is not
            a valid id.
        '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:
      x-resource-group: "ads"
      operationId: getConversionMetrics
      tags: [Conversions]
      x-platforms: ["meta", "google", "tiktok", "linkedin"]
      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:
      x-resource-group: "ads"
      operationId: listWhatsAppConversions
      tags: [WhatsApp]
      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:
      x-resource-group: "ads"
      operationId: sendWhatsAppConversion
      tags: [WhatsApp]
      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/messaging:
    post:
      x-resource-group: "ads"
      operationId: createMessagingAd
      tags: ["Messaging Ads"]
      x-platforms: ["meta"]
      summary: Create click-to-message ad (WhatsApp / Messenger / Instagram Direct)
      description: |-
        Creates a click-to-message ad; `destination` selects where the tapped ad opens a
        conversation: WhatsApp, the Page's Messenger inbox or the linked Instagram account's Direct inbox.
        The ad set is created with the matching destination_type and
        CONVERSATIONS optimization; the campaign objective defaults to OUTCOME_ENGAGEMENT.
        Supports single-creative and multi-creative shapes. Supersedes POST /v1/ads/ctwa
        (deprecated, equivalent to `destination: whatsapp`).
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/CtwaAdRequestBody'
                - type: object
                  required: [destination]
                  properties:
                    destination:
                      type: string
                      enum: [whatsapp, messenger, instagram_direct]
                      description: Where the conversation opens when the ad is tapped.
      responses:
        '201':
          description: Ad(s) created and submitted for review
        '400': { description: Invalid input }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
        '422': { description: "No Facebook Page resolved for the account" }

  /v1/ads/call:
    post:
      x-resource-group: "ads"
      operationId: createCallAd
      tags: ["Messaging Ads"]
      x-platforms: ["meta"]
      summary: Create Click-to-Call ad
      description: |-
        Same shape and flow as POST /v1/ads/ctwa, but the CTA is CALL_NOW dialing `phoneNumber`
        via a tel: link. The ad set is destination_type PHONE_CALL optimizing QUALITY_CALL
        and the campaign objective defaults to OUTCOME_LEADS.
        Supports the same single-creative and multi-creative shapes as CTWA.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              allOf:
                - $ref: '#/components/schemas/CtwaAdRequestBody'
                - type: object
                  required: [phoneNumber, linkUrl]
                  properties:
                    phoneNumber:
                      type: string
                      description: 'E.164 number the CALL_NOW CTA dials (e.g. +34600111222).'
                    linkUrl:
                      type: string
                      format: uri
                      description: "Website shown as the creative's link. Required: Meta rejects tel: as link_data.link; the phone number rides only the CTA."
      responses:
        '201':
          description: Ad(s) created and submitted for review
        '400': { description: Invalid input }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Account not found }
        '422': { description: "No Facebook Page resolved for the account" }

  /v1/ads/ctwa:
    post:
      x-resource-group: "ads"
      operationId: createCtwaAd
      tags: ["Messaging Ads"]
      x-platforms: ["meta"]
      summary: Create Click-to-WhatsApp ad (deprecated)
      deprecated: true
      description: |-
        Deprecated: use POST /v1/ads/messaging with `destination: whatsapp`. This endpoint stays available for back-compat; no removal planned.

        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.

        **Attach shape.** Send `adSetId` (with either creative shape) to add the ads to an EXISTING messaging ad set instead of building a campaign, so the ad set keeps its learning phase — the way to refresh a CTWA creative without resetting delivery. The ad set then owns budget, targeting and schedule, so `budgetAmount`, `budgetType`, `endDate`, `objective`, `countries`, `interests` and `audienceId` are rejected with a 400 alongside it rather than silently dropped. The target ad set's `destination_type` must match the ad's destination (a WhatsApp ad needs a `WHATSAPP` ad set), otherwise Meta would accept an ad that never delivers.

        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:
              $ref: '#/components/schemas/CtwaAdRequestBody'
      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:
      x-resource-group: "ads"
      operationId: listTrackingTags
      tags: ["Tracking Tags"]
      x-platforms: ["meta"]
      summary: List tracking tags
      description: |
        Returns the tracking tags (Meta Pixels, or OpenAI Ads pixels) the
        connected ads account can see. Pass `?adAccountId=act_...` (Meta only)
        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; OpenAI Ads has no get-by-id endpoint).

        Meta (platform `metaads`) and OpenAI Ads (platform `openaiads`); other
        platforms return 405. The `accountId` must be the ads SocialAccount
        created by the Ads add-on connect flow (Meta) or the OpenAI Ads
        connect flow, not a Facebook/Instagram posting account. Get your Meta
        `act_...` ids from `GET /v1/ads/accounts`; `adAccountId` is ignored for
        OpenAI Ads (one API key maps to exactly one ad account).
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string }, description: 'Ads SocialAccount id (platform `metaads` or `openaiads`).' }
        - { name: adAccountId, in: query, required: false, schema: { type: string }, description: 'Optional, Meta only. Scope to one ad account, e.g. `act_123456789`. Ignored for OpenAI Ads.' }
      responses:
        '200':
          description: Tracking tags listed
          content:
            application/json:
              schema:
                type: object
                properties:
                  platform: { type: string, enum: [metaads, openaiads] }
                  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:
      x-resource-group: "ads"
      operationId: createTrackingTag
      tags: ["Tracking Tags"]
      x-platforms: ["meta"]
      summary: Create a tracking tag
      description: |
        Meta: 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 Meta 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`.

        OpenAI Ads: creates an OpenAI pixel AND provisions a Conversions API
        key for it in the same call (`adAccountId` is required by this
        endpoint but ignored — one API key maps to exactly one ad account, so
        there's nothing to select). Returns 422 (`FEATURE_NOT_AVAILABLE`) if
        the ad account isn't enabled for pixel management; contact your OpenAI
        partner representative to enable it. There is no delete API for
        OpenAI pixels. If the pixel is created but the Conversions API key
        provisioning then fails, the pixel is left live on OpenAI (it cannot
        be cleaned up) and the error message names the surviving pixel id and
        warns against retrying, since a retry would create a second, orphaned
        pixel.

        NOT idempotent on either platform: each call creates a new pixel (and,
        for OpenAI, a new Conversions API key). Do not retry blindly on
        timeout. Meta (platform `metaads`) and OpenAI Ads (platform
        `openaiads`); other platforms return 405.
      security:
        - bearerAuth: []
      parameters:
        - { name: accountId, in: path, required: true, schema: { type: string }, description: 'Ads SocialAccount id (platform `metaads` or `openaiads`).' }
      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`. Required by this endpoint but ignored for OpenAI Ads.' }
                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, openaiads] }
                  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. }
        '422': { description: 'OpenAI Ads only: the ad account is not enabled for pixel management. Contact your OpenAI partner representative.' }

  /v1/accounts/{accountId}/tracking-tags/{tagId}:
    get:
      x-resource-group: "ads"
      operationId: getTrackingTag
      tags: ["Tracking Tags"]
      x-platforms: ["meta"]
      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. OpenAI Ads has no
        get-by-id endpoint, so it 405s here too — use
        `GET /v1/accounts/{accountId}/tracking-tags` (list) instead.
      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:
      x-resource-group: "ads"
      operationId: updateTrackingTag
      tags: ["Tracking Tags"]
      x-platforms: ["meta"]
      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:
      x-resource-group: "ads"
      operationId: listTrackingTagSharedAccounts
      tags: ["Tracking Tags"]
      x-platforms: ["meta"]
      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:
      x-resource-group: "ads"
      operationId: addTrackingTagSharedAccount
      tags: ["Tracking Tags"]
      x-platforms: ["meta"]
      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:
      x-resource-group: "ads"
      operationId: removeTrackingTagSharedAccount
      tags: ["Tracking Tags"]
      x-platforms: ["meta"]
      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:
      x-resource-group: "ads"
      operationId: getTrackingTagStats
      tags: ["Tracking Tags"]
      x-platforms: ["meta"]
      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. }

  /v1/verify/verifications:
    post:
      x-resource-group: "telephony"
      operationId: createVerification
      tags: [Verify]
      summary: Send a verification code
      description: |
        Generate a one-time code, deliver it to the recipient, and store only
        its hash. Check the user-typed code with
        POST /v1/verify/verifications/{verificationId}/check.

        Re-POSTing for the same (channel, to) while a verification is active
        RESENDS a fresh code on the existing verification (200 with
        `resend: true`) instead of creating a new one; resends are limited to
        one per 60 seconds (429 with `retryAfterSeconds` inside the cooldown).
        The stored brandName/codeLength/ttlMinutes win on a resend.

        Codes deliver by SMS from a phone number on your account (`from`
        optional when you own exactly one SMS-enabled number) and the message
        uses a fixed template. Each accepted send bills one verification fee
        plus the standard message rate.
      security:
        - bearerAuth: []
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [channel, to]
              properties:
                channel: { type: string, enum: [sms], description: 'SMS-only for now.' }
                to: { type: string, description: "E.164 phone number." }
                from: { type: string, description: "The SMS-enabled number on your account to send from. Defaults to your only SMS number." }
                brandName: { type: string, maxLength: 30, description: "Your app or business name, rendered in the message. Defaults to your account name. Letters, numbers, and basic punctuation only." }
                codeLength: { type: integer, minimum: 4, maximum: 8, default: 6 }
                ttlMinutes: { type: integer, minimum: 1, maximum: 15, default: 10 }
      responses:
        '201':
          description: Verification created and the code sent.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Verification'
        '200':
          description: 'Active verification found: a fresh code was resent (`resend: true`).'
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Verification'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '403': { description: Verifications require usage-based billing. }
        '404': { description: "The 'from' number is not an SMS-enabled number on this account." }
        '409': { description: The recipient has opted out of messages from your number. }
        '422': { description: "Verifications need an SMS-enabled number on your account; add one first." }
        '429': { description: 'Resend cooldown or a send cap was hit; `retryAfterSeconds` says when to retry.' }

  /v1/verify/verifications/{verificationId}:
    get:
      x-resource-group: "telephony"
      operationId: getVerification
      tags: [Verify]
      summary: Get a verification
      description: |
        Current state of a verification. `status` is effective (a pending code
        past its expiry reads as `expired`). Verification records are deleted
        24 hours after creation, after which this returns 404.
      security:
        - bearerAuth: []
      parameters:
        - { name: verificationId, in: path, required: true, schema: { type: string } }
      responses:
        '200':
          description: The verification.
          content:
            application/json:
              schema:
                $ref: '#/components/schemas/Verification'
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Verification not found (or already reaped). }

  /v1/verify/verifications/{verificationId}/check:
    post:
      x-resource-group: "telephony"
      operationId: checkVerification
      tags: [Verify]
      summary: Check a verification code
      description: |
        Verify the code the user typed. Wrong, expired, and exhausted codes
        answer 200 with `valid: false` and the settled `status` — only an
        unknown id is a 404. A correct code consumes the verification
        (single-use, `status: approved`) and fires the `verification.approved`
        webhook; the 5th wrong attempt settles it as `max_attempts_reached`
        and fires `verification.failed`.
      security:
        - bearerAuth: []
      parameters:
        - { name: verificationId, in: path, required: true, schema: { type: string } }
      requestBody:
        required: true
        content:
          application/json:
            schema:
              type: object
              required: [code]
              properties:
                code: { type: string, pattern: '^\d{4,8}$' }
      responses:
        '200':
          description: 'Check result: the verification plus `valid`.'
          content:
            application/json:
              schema:
                allOf:
                  - $ref: '#/components/schemas/Verification'
                  - type: object
                    properties:
                      valid: { type: boolean }
        '400': { $ref: '#/components/responses/BadRequest' }
        '401': { $ref: '#/components/responses/Unauthorized' }
        '404': { description: Verification not found (or already reaped). }
