{"openapi":"3.1.0","info":{"title":"Zernio API","version":"1.0.4","description":"API reference for Zernio. Authenticate with a Bearer API key.\nBase URL: https://zernio.com/api\n\nVersioning and deprecation: all endpoints are versioned in the URL path\n(current version: /v1). Breaking changes only ship in a new path version;\nexisting versions keep working. Deprecated operations are marked\n'deprecated: true' in this spec and announced in the changelog\n(https://zernio.com/changelog) before removal.\n\nErrors: every 4xx/5xx response is application/json with a machine-readable\n'code' and a human-readable 'error' message (see the ErrorResponse schema).\n","termsOfService":"https://zernio.com/tos","contact":{"name":"Zernio Support","url":"https://zernio.com","email":"support@zernio.com"},"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,\nmanage DMs, comments, and reviews across X, Instagram, WhatsApp, TikTok, LinkedIn,\nFacebook, YouTube, Threads, Reddit, Pinterest, Bluesky, Telegram, Google Business Profile, Snapchat,\nDiscord, and Slack, all from a single REST API. Run paid ads on Meta (Facebook + Instagram), Google,\nTikTok, LinkedIn, Pinterest, X, and OpenAI from the same account.\n\nKey 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.\n\nSupported posting platforms: X, Instagram, WhatsApp, Facebook, LinkedIn, TikTok, YouTube, Pinterest, Reddit, Bluesky, Threads, Google Business Profile, Telegram, Snapchat, Discord, Slack. Supported ad platforms: Meta Ads, Google Ads, TikTok Ads, LinkedIn Ads, Pinterest Ads, X Ads, OpenAI Ads. Snapchat is a closed beta with no public release date: connections are gated behind approval and return 403 `PLATFORM_BETA_RESTRICTED` until then.\n","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"}]},"x-documentation":{"readme":"# Zernio API\n\nThe social media API that replaces 14 integrations. Build social media features into your app in minutes, not months.\n\n## Quick Start\n\n**Base URL:** `https://zernio.com/api/v1`\n\n**Authentication:** All requests require a Bearer API key in the `Authorization` header.\n\n```bash\ncurl https://zernio.com/api/v1/user \\\n  -H \"Authorization: Bearer YOUR_API_KEY\"\n```\n\nGet your API key at [zernio.com/dashboard/api-keys](https://zernio.com/dashboard/api-keys).\n\n## Core Concepts\n\n| Concept | Description |\n|---------|-------------|\n| **Profiles** | Containers that organize accounts into brands or projects |\n| **Accounts** | Connected accounts belonging to a profile |\n| **Posts** | Content scheduled or published to one or more accounts |\n| **Queue** | Recurring time slots for automatic post scheduling |\n\n## Create a Post\n\n```bash\ncurl -X POST https://zernio.com/api/v1/posts \\\n  -H \"Authorization: Bearer $ZERNIO_API_KEY\" \\\n  -H \"Content-Type: application/json\" \\\n  -d '{\n    \"content\": \"Hello from the Zernio API.\",\n    \"scheduledFor\": \"2027-01-15T10:00:00Z\",\n    \"platforms\": [\n      { \"platform\": \"twitter\", \"accountId\": \"64e1f0a9e2b5af0012ab34cd\" },\n      { \"platform\": \"linkedin\", \"accountId\": \"66b2e19d8c3f5a7e9d0b1c2d\" }\n    ]\n  }'\n```\n\nThis single call publishes or schedules the post to every account in `platforms`, across any platform. Send `publishNow: true` instead of `scheduledFor` to publish immediately.\n\n## Supported Platforms\n\n| Platform | Post | Stories/Reels | Analytics | Inbox |\n|----------|------|---------------|-----------|-------|\n| X | Yes | - | Yes | Yes |\n| Instagram | Yes | Yes | Yes | Yes |\n| Facebook | Yes | Stories | Yes | Yes |\n| LinkedIn | Yes | - | Partial | - |\n| TikTok | Yes | - | Yes | Comments |\n| YouTube | Yes | Shorts | Yes | Yes |\n| Pinterest | Yes | - | Yes | - |\n| Reddit | Yes | - | Partial | Yes |\n| Bluesky | Yes | - | Partial | Yes |\n| Threads | Yes | - | Yes | Yes |\n| Google Business Profile | Yes | - | - | Yes |\n| Telegram | Yes | - | - | - |\n| Snapchat | Yes | - | - | - |\n\n> **Snapchat Note:** Snapchat is a closed beta with no public release date. Connecting a Snapchat account is gated behind approval and returns 403 `PLATFORM_BETA_RESTRICTED` until then.\n\n> **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.\n\n> **Google Business Profile Analytics Note:** Per-post analytics for Google Business Profile are deprecated by Google with no replacement, so Google Business Profile 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.\n\n> **TikTok Inbox Note:** Comment management (read, reply, hide, pin, like, delete) needs an account connected through the TikTok for Business app, which is what every new TikTok connection now uses. It does not require the TikTok account itself to be a Business Account. An account connected earlier through the TikTok developer app returns 400 `PLATFORM_LIMITATION` on the comment routes until it is reconnected. TikTok direct messages are not generally available: on top of the same connection they need a real TikTok Business Account whose sign-up region is outside the EEA, Switzerland and the UK, plus TikTok messaging approval for the calling app, and TikTok reports every one of those as a misleading \"access token is invalid\". Do not treat that as an expired token.\n\n## Rate Limits\n\nAPI request throughput is rate-limited per minute on a sliding window. Limits scale with your team's total connected accounts:\n\n- **0 to 2 accounts** (free tier): 60 req/min\n- **3 to 2,000 accounts**: 600 req/min\n- **2,001+ accounts**: 1,200 req/min\n\nLegacy AppSumo lifetime tiers get a flat 600 req/min regardless of tier.\n\nPosts themselves are unlimited on every connected 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 50/day, Pinterest 25/day, 50/day for every other platform), plus a 25-posts-per-hour per-account velocity cap.\n\nTikTok caps accounts connected through the TikTok for Business app lower than any of those: 15 videos and 15 photo posts per account per day, counted separately. Zernio enforces it as a trailing 24 hour window rather than a calendar day, and a post over the cap is not rejected. It is held and published automatically once the oldest post of the same kind leaves the window, so `scheduledFor` on that platform entry moves to the time it will go out.\n\nAll 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.\n\n## Webhooks\n\nReceive real-time notifications for post status changes, account events, and incoming messages:\n\n- `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\n- `post.published` - Post successfully published. If a platform target later fails background reconciliation, the post is re-rolled up and a `post.failed` or `post.partial` follows for the same post.\n- `post.failed` - Post failed on all platforms. Can follow an earlier `post.published` for the same post when background reconciliation discovers the publish never actually completed.\n- `post.partial` - Post published to some platforms, failed on others. Can follow an earlier `post.published` for the same post when background reconciliation discovers one target's publish never actually completed.\n- `post.cancelled` - Post publishing was cancelled\n- `post.recycled` - Post recycled (cloned and re-scheduled)\n- `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. A target that later fails background reconciliation (e.g. a Facebook video Meta accepted but never actually published) emits `post.platform.failed` afterward for the same target.\n- `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. Can fire after this same target already emitted `post.platform.published`, when background reconciliation later discovers the publish never actually completed on the platform's side.\n- `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.\n- `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.\n- `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\"`.\n- `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.\n- `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, and detection is a diff against posts a prior sync already indexed, so an account for which no post has ever been indexed never emits it.\n- `account.connected` - Account connected\n- `account.disconnected` - Account disconnected (token expired)\n- `account.ads.initial_sync_completed` - Initial ads sync (discovery + 90-day backfill) completed for an ads-enabled account\n- `analytics.synced` - One connected account's analytics sync cycle finished. A trigger, not a transport: it carries no metrics and no cursor, so call `GET /v1/analytics/delta` with your own last `nextCursor` to read what changed across every account. Poll-driven (~hourly per account), not real-time. High volume: subscribe to it on a dedicated endpoint.\n- `message.received` - New inbound DM or SMS received (SMS arrives with `platform: \"sms\"`)\n- `conversation.started` - A new conversation opened between one of your accounts and a contact, in either direction (any DM platform, and SMS); fires only the first time the thread appears\n- `conversation.control_changed` - WhatsApp only. Meta Business Agent took over a conversation or handed it to you. While `control.owner` is `ai_agent`, inbound messages arrive with `metadata.standby: true` and the agent answers them; sending a message takes control back\n- `message.sent` - DM sent via the API\n- `message.edited` - A sender edited a message (Instagram, Messenger, Telegram, WhatsApp)\n- `message.deleted` - A sender deleted (\"unsent\") a message (Instagram; WhatsApp in both directions, whether the business deleted a message it sent or the customer deleted one they sent). `message.direction` tells the two apart.\n- `message.delivered` - An outgoing message was delivered (WhatsApp, Messenger, SMS)\n- `message.read` - An outgoing message was read by the recipient (WhatsApp, Messenger, Instagram)\n- `message.failed` - An outgoing message failed delivery (WhatsApp, SMS; on SMS the carrier's error code rides in `error`)\n- `reaction.received` - A participant added or removed an emoji reaction on a message (WhatsApp, Telegram, Slack, Instagram, Facebook Messenger)\n- `referral.received` - Someone opened an existing Instagram/Messenger thread through an ig.me/m.me `ref` link or a returning Messenger ad click; carries Meta's referral object (a referral that rides an inbound message arrives on `message.received` under `metadata.referral` instead)\n- `comment.received` - New comment received on a post (Instagram, Facebook, Threads, YouTube, LinkedIn, Bluesky, Reddit, TikTok). X/Twitter does NOT fire this event\n- `review.new` - New review posted on a connected account (Google Business Profile)\n- `review.updated` - A Google Business Profile reviewer edited their review text or rating, or a reply was posted through `POST /v1/inbox/reviews/{reviewId}/reply`. A reply written directly in Google's own interface does NOT fire this event, because Google emits no notification for it.\n- `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.\n- `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)\n- `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\".\n- `whatsapp.template.category_updated` - Meta reclassified a WhatsApp Business template's category (drives tariff and marketing opt-out applicability). `template.changeType` is `scheduled` (24h advance notice) or `applied`; `template.category` is always the category right now.\n- `whatsapp.account.name_status_updated` - Meta finished reviewing a WhatsApp display-name change. `name.status` carries the outcome (APPROVED, DECLINED, PENDING_REVIEW). Fires only on a review outcome; a name applied without review (`name_status: AVAILABLE_WITHOUT_REVIEW`) produces no event.\n- `call.received` - An inbound call (phone/PSTN or WhatsApp) reached one of your numbers and was routed to its destination\n- `call.ended` - A call ended; carries duration, end reason, cost breakdown, and the recording when enabled\n- `call.failed` - A call failed with a hard error before or during bridging\n- `call.permission_request` - A WhatsApp user accepted or rejected your call-permission request\n- `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\n- `phone_number.stock_available` - An out-of-stock country you watch (POST /v1/phone-numbers/stock-watches) has deliverable numbers again. Fires once per watch, then the watch is consumed. Carries the deliverable count per number type at sweep time (first come, first served).\n- `whatsapp.number.kyc_submitted` - An end customer completed a hosted KYC share link; the number entered regulatory review under your account\n- `whatsapp.number.activated` - A provisioned WhatsApp number finished setup and is ready to connect\n- `whatsapp.number.declined` - A regulated number order was declined in review; nothing activates and nothing is billed\n- `whatsapp.number.action_required` - The regulator asked for more information on a placed number order; the order stays pending until provided\n- `whatsapp.number.verification_required` - A regulated number needs end-user ID verification; carries the link to forward\n- `whatsapp.number.suspended` - An active number was suspended (e.g. failed payment); carries a `reason`\n- `whatsapp.number.reactivated` - A suspended number is usable again\n- `whatsapp.number.released` - A number was released and is no longer usable (terminal); carries a `reason`\n- `verification.approved` - A managed-OTP verification was approved (the user submitted the correct code)\n- `verification.failed` - A managed-OTP verification was exhausted after too many wrong code attempts\n- `webhook.test` - Test event sent when verifying a webhook endpoint\n\nEvery delivery carries `X-Zernio-Event` (the event name) and `X-Zernio-Event-Id` (the payload's `id`, a UUID generated once when the event is emitted). That id is identical on every subscription that receives the event, on every automatic retry and on a redelivery via `POST /v1/webhooks/logs/redeliver`, so it is the key to dedupe on. Payloads are signed with HMAC-SHA256 via the `X-Zernio-Signature` header when the subscription has a secret. Legacy `X-Late-*` copies of these three headers are still sent and will be removed in a future major version.\n\n## Full Documentation\n\nFor complete guides, platform-specific details, and SDK references, visit [docs.zernio.com](https://docs.zernio.com).\n\n## SDKs\n\nOfficial 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.\n"},"servers":[{"url":"https://zernio.com/api","description":"Production"},{"url":"http://localhost:3000/api","description":"Local"}],"tags":[{"name":"Tools","description":"Media tools for authenticated API consumers."},{"name":"Posts","description":"Create, schedule, list, update, and delete posts across all connected accounts.\n"},{"name":"Blogs","description":"Manage blogs and blog articles on connected accounts. Currently supported\nfor Shopify accounts (scopes read_content and write_content). Blogs are\nthe containers; articles are the posts inside them. All content lives on\nthe platform; Zernio proxies it and stores nothing.\n"},{"name":"Users","description":"Read the authenticated user and team members.\n"},{"name":"Usage","description":"Usage and metering. `GET /v1/usage` is dual-mode: bare calls return the\nplan / quota snapshot (back-compat, same as `/v1/usage-stats`); calls\nwith `range` / `granularity` params return usage METERING: billed spend\n(USD) by product family (accounts, numbers, calls, sms, dlc, xApi,\ncredits) over any window, from the usage-based invoice breakdown (also served\nat `GET /v1/usage/daily`). The `/v1/usage/<domain>` spokes (calls, sms)\nmeter per-domain consumption volumes over a window. `GET /v1/billing` is\nthe billing statement (balance, credits, caps, payment status).\n"},{"name":"Profiles","description":"Manage profiles (named groups of accounts).\n"},{"name":"Accounts","description":"Manage connected accounts: list, fetch, update, disconnect, and read account health.\n"},{"name":"Account Groups","description":"Manage account groups (collections of accounts used for cross-posting and organization).\n"},{"name":"API Keys","description":"Create, list, and revoke API keys used to authenticate requests.\n\nResource 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.\n"},{"name":"Connected Apps","description":"List and revoke the OAuth clients (AI assistants and MCP connectors) authorized\non the account.\n\nConnector 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.\n"},{"name":"Invites","description":"Generate invite tokens for adding members to a team.\n"},{"name":"Connect","description":"OAuth and credential flows for connecting accounts, plus per-platform selection\nsteps (Facebook pages, Pinterest boards, LinkedIn organizations, Google Business Profile locations, etc.).\n"},{"name":"Media","description":"Upload and presign media (images, videos, documents) for use in posts.\n"},{"name":"Reddit Search","description":"Search Reddit posts and browse subreddit feeds.\n"},{"name":"GMB Reviews","description":"Google Business Profile reviews: list reviews and reply to them.\n"},{"name":"GMB Food Menus","description":"Read and update Google Business Profile food menus.\n"},{"name":"GMB Location Details","description":"Read and update Google Business Profile location details.\n"},{"name":"GMB Media","description":"Manage Google Business Profile media (photos and videos): list, upload, and delete.\n"},{"name":"GMB Attributes","description":"Read and update Google Business Profile attributes.\n"},{"name":"GMB Place Actions","description":"Manage Google Business Profile place action links (booking, ordering, reservations, etc.).\n"},{"name":"Discord","description":"Discord-specific endpoints for managing webhook identity (display name and avatar), switching channels, and listing guild channels.\n"},{"name":"LinkedIn Mentions","description":"Resolve LinkedIn organization and person mentions for use in posts.\n"},{"name":"Instagram","description":"Instagram-specific read endpoints: list a connected account's Stories and fetch\nper-Story insights. All endpoints require an accountId parameter identifying the\nInstagram-connected account.\n"},{"name":"Queue","description":"Manage posting-queue time slots and preview the upcoming queue.\n"},{"name":"Analytics","description":"Post and account analytics across platforms (insights, demographics, follower history,\nbest time to post, content decay, and aggregated metrics).\n"},{"name":"Inbox Access","description":"Check and manage inbox feature access.\n"},{"name":"Messages","description":"Unified inbox API for managing conversations and direct messages across all connected accounts.\nAll endpoints aggregate data from multiple accounts in a single API call.\nRequires Inbox addon.\n"},{"name":"Comments","description":"Unified inbox API for managing comments on posts across all connected accounts.\nSupports commenting on third-party posts for platforms that allow it (YouTube, X, Reddit, Bluesky, Threads).\nAll endpoints aggregate data from multiple accounts in a single API call.\nRequires Inbox addon.\n"},{"name":"Reviews","description":"Unified inbox API for managing reviews on Facebook Pages and Google Business Profile accounts.\nAll endpoints aggregate data from multiple accounts in a single API call.\nRequires Inbox addon.\n"},{"name":"Mentions","description":"Unified inbox API for managing mentions across connected accounts.\nCurrently supports LinkedIn organization mentions.\nRequires Inbox addon.\n"},{"name":"Twitter Engagement","description":"X-specific engagement endpoints for retweeting, bookmarking, and following.\nRate limits: 50 requests per 15-min window per user. Retweets share the 300/3hr creation limit with tweet creation.\n"},{"name":"Validate","description":"Pre-flight validation endpoints. Check post content, character limits, media URLs, and subreddit existence before publishing.\n"},{"name":"Account Settings","description":"Platform-specific account settings: Facebook persistent menu, Instagram ice breakers, and Telegram bot commands.\n"},{"name":"Contacts","description":"Cross-platform contact management (CRM). Contacts are unified identities linked to platform-specific\nchannels (phone, IGSID, etc.). Created automatically when messages arrive, or manually via API.\n"},{"name":"Custom Fields","description":"Custom field definitions for contacts. Define fields (text, number, date, boolean, select) that can be\nset on any contact for segmentation and personalization.\n"},{"name":"Broadcasts","description":"Platform-agnostic broadcast campaigns. Send bulk messages to contacts via any inbox platform.\nWhatsApp broadcasts use templates; other platforms use generic messages.\n"},{"name":"Sequences","description":"Drip campaign sequences. Send a series of messages to enrolled contacts with configurable delays\nbetween steps. Supports auto-exit on reply or unsubscribe.\n"},{"name":"Workflows","description":"Branching conversation automations. An inbound message matches a workflow's trigger and walks a\ndirected graph of nodes (send message, wait for reply, condition, set variable, delay, webhook,\nhandoff, end). Unlike Sequences (linear, time-based drips), Workflows are event-driven and\ninteractive. Fully supported on WhatsApp, Instagram, and Messenger; `send_message` template and\ninteractive modes are WhatsApp-only.\n"},{"name":"Comment Automations","description":"Comment-to-DM growth automations. Set up keyword triggers on Instagram/Facebook so\ncommenters automatically receive a DM. Scope per post or account-wide (omit\n`platformPostId` to match comments on every post on the account, with unlimited\nautomations stacked per account). Supports dedup, optional public comment reply, and\nauto-creates contacts.\n"},{"name":"Ad Campaigns","description":"The advertising structure: campaigns, ad sets, and ads. Create standalone campaigns/ads, boost an\norganic post, duplicate at any level, pause/resume, and read the full tree and timeline. Campaigns\nare virtual aggregations of ads grouped by their platform campaign ID.\nPer-operation platform support is shown by the platform badges. Requires the Ads add-on.\n"},{"name":"Ad Creatives","description":"Creative assets: the standalone creative library (create/reuse/rename), the ad-account image library\n(list + base64 upload), rendered ad previews, and product catalogs for Advantage+/dynamic ads. Meta only.\nRequires the Ads add-on.\n"},{"name":"Ad Audiences","description":"Custom audiences for targeting: customer lists (hashed upload), website + engagement + lookalike\naudiences, and reusable saved-targeting presets. Creation support varies by platform (badges show which);\nLinkedIn adds company-list and engagement segments.\nRequires the Ads add-on.\n"},{"name":"Ad Targeting","description":"Targeting discovery: search interests/behaviors/geo/demographics, estimate reach, and (LinkedIn)\nbid pricing and supply forecasts. Feeds the targeting fields on create.\nRequires the Ads add-on.\n"},{"name":"Ad Library","description":"Competitor and market research over the public ad archives (Meta Ad Library, LinkedIn Ad Library),\nsearched with the customer's own connected token. Requires the Ads add-on.\n"},{"name":"Ad Insights","description":"Measurement: cached aggregate analytics per ad/campaign, plus live Meta Graph insight queries\n(arbitrary fields, breakdowns, filtering, attribution windows) and async report runs.\nRequires the Ads add-on.\n"},{"name":"Conversions","description":"Server-side Conversions API: send + adjust conversion events (with hashed matching and consent/LDU\nforwarding), read Event Match Quality, and manage conversion destinations (pixels/datasets) and their\nad-account associations. Supported on Meta, Google, TikTok and LinkedIn (badges show per operation).\nRequires the Ads add-on.\n"},{"name":"Messaging Ads","description":"Click-to-message and click-to-call destination ads: WhatsApp (CTWA), Messenger, Instagram Direct,\nand Call ads. Meta only.\nRequires the Ads add-on.\n"},{"name":"Reach and Frequency","description":"Fixed-price reserved (Reach & Frequency) buying: quote a prediction, reserve price + inventory, and\nbuy via a RESERVED campaign. Meta only.\nRequires the Ads add-on.\n"},{"name":"Lead Gen","description":"Instant lead forms on Facebook Pages: create/list/archive forms and retrieve (or test) their leads.\nMeta only.\nRequires the Ads add-on.\n"},{"name":"Ad Accounts","description":"Ad accounts and operational/diagnostic reads: list accounts, account finances, change/audit log,\nA/B studies, high-demand periods, ad labels, DSA defaults + recommendations, and Business Managers\n(Meta) / Business Centers (TikTok).\nRequires the Ads add-on.\n"},{"name":"Tracking Tags","description":"Manage the platform measurement tag: the thing you create, install on a website, send events to,\nand target ads against. On Meta this is a Pixel; the surface is platform-neutral so other platforms\n(Pinterest Tag, LinkedIn Insight Tag, etc.) can be added without changing the contract. Create a tag,\nget it (including the install code snippet), rename + adjust matching/cookie/data-use settings, share\nit with ad accounts, and read aggregated event stats. Currently Meta-only.\nRequires the Ads add-on.\n"},{"name":"Business Agent","description":"Provision and operate Meta Business Agent, Meta's own AI agent, on a connected WhatsApp number\nwithout the merchant opening Business Manager. Check eligibility and setup state, create the agent,\nturn it on or off, feed it knowledge (business information, FAQs, websites, files), give it skills\nand UI skills, wire connectors and tools to your own APIs, cap usage with budgets, test it in Meta's\nsandbox, send it business events, and run evaluations.\n\nTwo steps stay manual by Meta's design and have no API: the merchant accepts the Meta Business Agent\nterms in WhatsApp Manager (until then every call answers `business_agent_terms_not_accepted`), and a\npayment method is attached to the Business Agent billable account in Billing Hub (without it the agent\nis on but Meta delivers no messages; allowlisted-only testing is exempt). `GET .../business-agent`\nreports both.\n\nWhile the agent answers a conversation your inbox receives it on the standby path and a reply from\nZernio takes control back. Every call needs the WhatsApp social account id, the Inbox add-on, and a\nnumber managed through the Cloud API (a coexistence number that still runs the in-app Business AI is\nnot eligible).\n"},{"name":"Webhooks","description":"Configure webhooks for real-time notifications. Webhooks can be created from the dashboard (Settings → Webhooks) or via this API.\nEvents: 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, analytics.synced, message.received, conversation.started, conversation.control_changed, call.received, call.ended, call.failed, call.permission_request, message.sent, message.edited, message.deleted, message.delivered, message.read, message.failed, reaction.received, referral.received, comment.received, review.new, review.updated, lead.received, ad.status_changed, whatsapp.template.status_updated, whatsapp.template.category_updated, whatsapp.account.name_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, phone_number.stock_available, verification.approved, verification.failed, webhook.test.\nHeaders: every delivery carries X-Zernio-Event (event name) and X-Zernio-Event-Id (the payload `id`, a UUID minted once per event and identical across every subscribed endpoint, every automatic retry and every redelivery, so dedupe on it). Security: optional HMAC-SHA256 signature in X-Zernio-Signature header. Configure a secret key to enable verification. Custom headers supported. Legacy X-Late-Event, X-Late-Event-Id and X-Late-Signature duplicates are still sent and will be removed in a future major version.\n\nResource 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.*, whatsapp.template.* and whatsapp.account.* to accounts, analytics.* to analytics, 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.\n"},{"name":"Webhook Events","description":"Incoming webhook deliveries sent by Zernio to your configured endpoint URL.\n"},{"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.\n"},{"name":"WhatsApp","description":"WhatsApp Business API. Template, business profile, and phone number endpoints.\nAll endpoints require an accountId parameter identifying the WhatsApp-connected account.\n"},{"name":"WhatsApp Calling","description":"Voice calling over the WhatsApp Business API: enable/disable calling on a number,\nconfigure call hours and permissions, and place or list calls.\nAll endpoints require an accountId parameter identifying the WhatsApp-connected account.\n"},{"name":"WhatsApp Templates","description":"Browse Meta's pre-approved WhatsApp template library. Use these read-only lookups to\ndiscover library templates you can import as your own message templates.\nAll endpoints require an accountId parameter identifying the WhatsApp-connected account.\n"},{"name":"WhatsApp Flows","description":"WhatsApp Flows let you build native interactive forms, surveys, and booking experiences inside WhatsApp.\nFlows are created in DRAFT status, populated with a Flow JSON definition, then published for sending.\nPublished flows are immutable; to update, create a new flow (optionally cloning the old one).\nAll endpoints require an accountId parameter identifying the WhatsApp-connected account.\n"},{"name":"Phone Numbers","description":"Buy and manage phone numbers. A phone number is a platform-independent unit;\nWhatsApp, SMS, and Voice are features you enable on it. Purchase, search\ninventory, complete country KYC, and release numbers here; turn features on\nvia the per-number sub-resources (`/v1/phone-numbers/{id}/voice`, `/sms`,\n`/whatsapp/...`). Requires a paid plan.\n"},{"name":"Verify","description":"Managed one-time passcodes (OTP) for phone verification. Two calls\nhandle the whole lifecycle: create a verification (we generate the\ncode, deliver it by SMS from a phone number on your account, and store\nonly its hash) and check the code the user typed. Usage-based billing\nonly.\n"},{"name":"WhatsApp Phone Numbers","description":"Deprecated namespace: phone numbers moved to `/v1/phone-numbers` (a number is\nplatform-independent; WhatsApp is one feature on it). These aliases keep\nworking with the same contracts, but new integrations should use the\nPhone Numbers endpoints.\n"},{"name":"WhatsApp Sandbox","description":"Shared WhatsApp sandbox: a Zernio-owned WhatsApp number every user can test against\nwithout provisioning their own. Send the verified sandbox template to phones you\nactivate via a reply-based verification flow. Designed for testing message flows,\nbot replies, and webhook payloads end-to-end with zero number-purchase overhead.\n"},{"name":"Calls","description":"Unified call history across every number you own: WhatsApp Business Calling and\nregular phone (PSTN) calls in one list, newest first, without fanning out one\nrequest per number. Each row carries `channel` and `accountId` so you can fetch\ndetails and recordings from the matching channel-specific endpoint.\nPrivate beta: returns 403 unless your account is enrolled.\n"},{"name":"Voice","description":"Regular phone (PSTN) calling on your numbers. Enable voice on a number and route\ninbound calls to your own AI voice agent (Vapi/Retell), a phone, or a SIP endpoint,\nwith voicemail, business-hours routing, IVR, recording, and transcription. Place\noutbound calls, manage live ones (end, blind-transfer), resolve recordings, and\ndrive the browser softphone.\nPrivate beta: returns 403 unless your account is enrolled.\n"},{"name":"SMS","description":"SMS/MMS on your numbers: enable SMS on a number, send messages, validate recipient\nnumbers, export STOP opt-outs, and complete the US carrier registration (10DLC or\ntoll-free) required before US traffic delivers.\nPrivate beta: returns 404 unless your account is enrolled.\n"}],"components":{"securitySchemes":{"bearerAuth":{"type":"http","scheme":"bearer","bearerFormat":"JWT","description":"API key authentication: send your Zernio API key in the Authorization header, prefixed with Bearer."},"connectToken":{"type":"apiKey","in":"header","name":"X-Connect-Token","description":"Short-lived connect token for API users during OAuth flows. \nAutomatically generated when initiating OAuth without a browser session.\nValid for 15 minutes. Used to authenticate Facebook page selection API calls.\n"}},"parameters":{"BusinessAgentAccountId":{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"WhatsApp social account id (the number must be managed through the Cloud API)."},"BusinessAgentConnectorId":{"name":"connectorId","in":"path","required":true,"schema":{"type":"string"}},"BusinessAgentToolId":{"name":"toolId","in":"path","required":true,"schema":{"type":"string"}},"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."}},"headers":{"BackfillRetryAfter":{"description":"Recommended delay before retrying, in seconds.","schema":{"type":"integer","example":60}}},"responses":{"AccountUnavailable":{"description":"The account or requested resource was not found or is not accessible. An account ID may have been disconnected and removed. Read GET /v1/accounts for current account IDs.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Account ID not found. It may have been disconnected and removed. Read GET /v1/accounts for current account IDs.","type":"not_found","code":"account_not_found","param":"accountId"}}}},"AccountConnectionRequired":{"description":"The account exists but is inactive or needs reconnection. Reconnect it, then read GET /v1/accounts for its current account ID before retrying. Code: ads_connection_required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"This account needs reconnection. Reconnect the account, then read GET /v1/accounts for its current account ID before retrying.","type":"invalid_request_error","code":"ads_connection_required","param":"accountId"}}}},"TemporarilyUnavailable":{"description":"An upstream service or database is temporarily unavailable. Retry after the indicated delay. A timed-out write may have completed upstream; check its outcome before resubmitting.","headers":{"Retry-After":{"schema":{"type":"integer","example":5},"description":"Minimum delay in seconds before retrying."}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Temporary connection issue while reaching an upstream service. Please retry the request.","type":"api_error","code":"temporarily_unavailable"}}}},"UpstreamFailure":{"description":"The platform returned a server error.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"The platform could not complete the request.","type":"platform_error","code":"platform_api_error"}}}},"IdempotencyKeyInFlight":{"description":"Same Idempotency-Key still processing; retry after a short backoff"},"IdempotencyKeyReused":{"description":"Idempotency-Key reused with a different request"},"BusinessAgentForbidden":{"description":"Inbox add-on required, the WhatsApp token lacks the Business Agent permissions (code reconnect_required), or the merchant has not accepted the Meta Business Agent terms in WhatsApp Manager (code business_agent_terms_not_accepted).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"BusinessAgentNotFound":{"description":"Account not found, or no agent exists on the number yet or the referenced item does not exist (code business_agent_not_found).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"BusinessAgentDeleted":{"description":"Deleted","content":{"application/json":{"schema":{"type":"object","required":["success"],"properties":{"success":{"type":"boolean"}}}}}},"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.\n\nReddit rate-limits per connected Reddit user (1000 requests per\n10-minute window), and that budget is shared by every operation using\nthat account. Retry after the window resets rather than retrying\nimmediately; repeated calls while exhausted do not succeed and keep the\nbudget spent.\n","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\naccount hit a billing gate before the connection could proceed.\nThree reasons:\n\n  - `free_tier_exceeded`: the team has connected more accounts\n    than the free tier allows. Add a payment method on the\n    dashboard to continue (the user will be billed per\n    additional connected account).\n\n  - `twitter_passthrough`: connecting an X account\n    requires a card on file from day one because X API calls\n    incur real per-call pass-through costs. Applies to the 1st\n    X account, not only the 3rd+.\n\n  - `enterprise_required`: the team is on an enterprise\n    contract with a negotiated connected-account cap and has\n    reached it. Self-service teams have NO connected-account cap (the\n    $1/account rate continues at any scale), so this reason can\n    only fire for teams whose contract sets an explicit limit.\n    `dashboard_url` deep-links to the enterprise contact page\n    rather than the billing tab. The end-user already has a\n    card on file; this gate is about contract terms, not card\n    collection.\n\nSDK consumers should switch on `reason` to render the right\nprompt. For `free_tier_exceeded` and `twitter_passthrough`,\nredirect the end-user to `dashboard_url` to add a payment method\nvia Zernio's hosted Stripe Setup Checkout. For\n`enterprise_required`, redirect to `dashboard_url` (the\nenterprise contact form) to adjust the contract's limit.\n","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\n`free_tier_exceeded` and `twitter_passthrough` this is\nthe Zernio billing tab. For `enterprise_required` this\nis the Zernio enterprise contact page.\n","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\nteam's enterprise contract. Self-service teams\nhave no cap and never receive this reason. Only\nset when reason=enterprise_required.\n","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":{"WhatsAppTemplateLookupError":{"type":"object","required":["error","type","code","platform","details"],"properties":{"error":{"type":"string"},"type":{"type":"string","enum":["platform_error"]},"code":{"type":"string","enum":["platform_api_error"]},"platform":{"type":"string","enum":["whatsapp"]},"platformError":{"type":"object","description":"Sanitized Meta lookup error fields, present when Meta returned them.","properties":{"code":{"type":"integer"},"message":{"type":"string"},"error_data":{"type":"object","properties":{"details":{"type":"string"}}}}},"details":{"type":"object","required":["phase","endpoint"],"properties":{"phase":{"type":"string","enum":["template_lookup"]},"endpoint":{"type":"string","description":"Meta endpoint path without query parameters or access tokens"},"upstreamStatus":{"type":"integer"},"providerHeaders":{"type":"object","description":"Safe provider headers retained from the lookup response.","properties":{"retry-after":{"type":"string"},"x-app-usage":{"type":"string"},"x-business-use-case-usage":{"type":"string"}},"additionalProperties":false}}}}},"AdTracking":{"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` is stored on the new creative as `url_tags` and retained on the ad for compatibility. 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 stored on the creative as `url_tags` and returned by GET /v1/ads/{adId}/tracking-tags. App-promotion linkUrl stays byte-identical to promotedObject.objectStoreUrl. 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"}}}}}},"MetaLeadForm":{"type":"object","description":"A Meta Lead Gen form as Graph returns it, in Meta's own snake_case. Read through GET /v1/ads/lead-forms/{formId}. Every setting POST /v1/ads/lead-forms writes is present here, so a form can be diffed against what was created and drift from edits made in Meta's form builder is detectable. A compound field is omitted entirely when the form has no value for it, and `fields` narrows the selection.\n","properties":{"id":{"type":"string"},"name":{"type":"string"},"status":{"type":"string","description":"One of ACTIVE, ARCHIVED, DELETED or DRAFT."},"locale":{"type":"string","example":"en_US"},"created_time":{"type":"string","format":"date-time"},"page_id":{"type":"string","description":"Owning Facebook Page. A form on any other Page is a 404, whether read or archived."},"leads_count":{"type":"integer"},"organic_leads_count":{"type":"integer"},"expired_leads_count":{"type":"integer","description":"Leads Meta has aged out of the retention window."},"privacy_policy_url":{"type":"string","format":"uri"},"follow_up_action_url":{"type":"string","format":"uri"},"follow_up_action_text":{"type":"string"},"question_page_custom_headline":{"type":"string"},"is_optimized_for_quality":{"type":"boolean"},"block_display_for_non_targeted_viewer":{"type":"boolean"},"allow_organic_lead":{"type":"boolean","description":"Whether the form can also be submitted from an organic Page post."},"tracking_parameters":{"type":"array","description":"Custom key/value pairs attached to every lead of this form.","items":{"type":"object","properties":{"key":{"type":"string"},"value":{"type":"string"}}}},"legal_content":{"type":"object","description":"Privacy policy and custom disclaimer as Meta stores them.","properties":{"id":{"type":"string"},"privacy_policy":{"type":"object","properties":{"url":{"type":"string","format":"uri"},"link_text":{"type":"string"}}},"custom_disclaimer":{"type":"object","description":"Set in Meta form builder only; there is no create parameter for it."}}},"context_card":{"type":"object","properties":{"id":{"type":"string"},"title":{"type":"string"},"style":{"type":"string","enum":["LIST_STYLE","PARAGRAPH_STYLE"]},"content":{"type":"array","items":{"type":"string"}},"button_text":{"type":"string"},"cover_photo":{"type":"object","properties":{"id":{"type":"string"}}}}},"thank_you_page":{"type":"object","description":"The form's single ending page, mirroring the thankYou* create fields. Meta has exactly one per form; there is no multiple-ending-page API (thank_you_pages and ending_pages are not Graph fields).\n","properties":{"id":{"type":"string"},"title":{"type":"string"},"body":{"type":"string"},"button_text":{"type":"string"},"button_type":{"type":"string","example":"VIEW_WEBSITE"},"website_url":{"type":"string","format":"uri"},"enable_messenger":{"type":"boolean"},"status":{"type":"string"},"lead_gen_use_case":{"type":"string"},"business_phone_number":{"type":"string"},"country_code":{"type":"string"}}},"questions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"key":{"type":"string"},"label":{"type":"string"},"type":{"type":"string","description":"EMAIL, PHONE, FULL_NAME, CUSTOM, ..."},"inline_context":{"type":"string"},"options":{"type":"array","items":{"type":"object","properties":{"key":{"type":"string"},"value":{"type":"string"}}}},"conditional_questions_group_id":{"type":"string","description":"READ-ONLY. Conditional logic can only be authored in Meta form builder; Meta has no create parameter for it."},"conditional_questions_choices":{"type":"array","items":{"type":"object"},"description":"READ-ONLY. Which answers reveal the conditional group."},"dependent_conditional_questions":{"type":"array","items":{"type":"object"},"description":"READ-ONLY. Questions revealed by the conditional group."}}}}}},"MetaInstagramIdentityRef":{"type":"object","required":["igUserId","username"],"properties":{"igUserId":{"type":"string","description":"Instagram identity ID."},"username":{"type":"string","description":"Instagram username; empty when Meta does not expose it."},"profilePictureUrl":{"type":"string","description":"Profile picture URL when available."}}},"GoogleBusinessReview":{"type":"object","description":"A Google Business Profile review, as returned by every gmb-reviews read endpoint.","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":0,"maximum":5,"description":"Numeric star rating (0 when Google sends no 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"}}}}}},"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\nEXACTLY ONE of the two shapes:\n\n- Single-creative: `headline`, `body`, and one of `imageUrl` / `video`,\n  OR `existingPostId` / `objectStoryId` to reuse an organic post.\n- Multi-creative: a non-empty `creatives[]` array. Top-level\n  creative fields must NOT be set on this shape.\n\nExisting post references work on messaging and CTWA only (not call ads).\nThey cannot be combined with each other or with headline, body, imageUrl,\nvideo, or welcomeMessage. No media is uploaded and the organic post is\nretained. Fresh creatives still require headline, body, and image or video.\n\nThe route enforces this at the Zod boundary; OpenAPI's\n`required` cannot express the OR cleanly.\n","properties":{"creativeFeatures":{"$ref":"#/components/schemas/MetaCreativeFeatures","description":"Meta enhancement settings for single or attached ads, and defaults for creatives[]. An item replaces the entire map, including with an empty object."},"tracking":{"$ref":"#/components/schemas/AdTracking"},"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.\nOn the multi-creative shape, each ad's Meta name gets a\n\" #N\" suffix (1-indexed) so Ads Manager shows them as a\nnumbered batch.\n"},"existingPostId":{"type":"string","minLength":1,"description":"Messaging and CTWA only. Platform post or reel ID, resolved like boost platformPostId. Facebook IDs become object_story_id; Instagram IDs become source_instagram_media_id using the connected Instagram identity. Mutually exclusive with objectStoryId and fresh creative fields."},"objectStoryId":{"type":"string","pattern":"^\\d+_\\d+$","description":"Messaging and CTWA only. Raw Facebook pageId_postId reference, used as object_story_id even with an Instagram account. Mutually exclusive with existingPostId and fresh creative fields."},"whatsappPhoneNumber":{"type":"string","pattern":"^\\+[1-9]\\d{6,14}$","description":"WhatsApp only. Optional E.164 number already paired with the Facebook Page. Omit to let Meta select the paired number. Sent to the creative CTA and, when creating a new ad set, its promoted_object. Attach requests do not change the existing ad set. Stored as creative.whatsappPhoneNumber on every created ad."},"headline":{"type":"string","minLength":1,"maxLength":255,"description":"Single-creative shape only. Mutually exclusive with\n`creatives[]`.\n"},"body":{"type":"string","minLength":1,"description":"Primary text shown above the image / video. Single-creative\nshape only. Mutually exclusive with `creatives[]`.\n"},"imageUrl":{"type":"string","format":"uri","description":"Image asset for single-creative shape. Mutually exclusive\nwith `video` and with `creatives[]`. Required on the\nsingle-creative shape if neither `video` nor an existing post reference is supplied.\n"},"video":{"type":"object","properties":{"url":{"type":"string","format":"uri","description":"Public URL of the video to upload. Provide either `url` or `id`."},"id":{"type":"string","description":"Reuse a video already uploaded to this ad account (list them with GET /v1/ads/videos) instead of re-uploading. Wins over `url`. Provide either `url` or `id`."},"thumbnailUrl":{"type":"string","format":"uri","description":"OPTIONAL: when omitted, the poster is auto-generated from\nMeta's own preferred video thumbnail. When Meta produces no\ncandidate the request fails with a 502 platform_error\n(reason: video_thumbnail_unavailable). Retry, or supply\nthis field to control the poster frame exactly.\n"}},"description":"Video creative for single-creative shape. Mutually\nexclusive with `imageUrl` and with `creatives[]`. Required\non the single-creative shape if neither `imageUrl` nor an existing post reference is supplied.\n"},"welcomeMessage":{"type":"object","required":["text","prefillText"],"properties":{"text":{"type":"string","minLength":1,"description":"Greeting shown when the chat opens. Replaces Meta's default (\"Hi! Can we help you?\")."},"prefillText":{"type":"string","minLength":1,"description":"Message put into the user's text input, ready to send. Replaces Meta's default (\"Hi! I want more info.\"). Lets one ad steer the opening message toward what it promotes (e.g. a specific product)."}},"description":"Custom chat welcome message (Meta's `page_welcome_message`,\n\"Mensaje de bienvenida\" / \"Mensaje predefinido\" in Ads Manager).\nSingle-creative shape only; for `creatives[]` set it per entry.\n"},"creatives":{"type":"array","minItems":1,"description":"Multi-creative shape: N CTWA ads under one campaign + one\nad set, sharing budget and targeting. Mutually exclusive\nwith the top-level single-creative fields (`headline` /\n`body` / `imageUrl` / `video`): setting both is a 400,\nunlike `POST /v1/ads/create` where the top-level fields\nare silently ignored in multi-creative mode. Each entry\nsupplies headline, body, and image/video, or an existingPostId or\nobjectStoryId reference. Fresh and existing creatives can be mixed.\n","items":{"type":"object","description":"Supply headline, body, and image/video, or exactly one existing post reference. References cannot be combined with fresh creative fields.","properties":{"existingPostId":{"type":"string","minLength":1,"description":"Messaging and CTWA only. Platform post or reel ID, resolved like boost platformPostId. Facebook IDs become object_story_id; Instagram IDs become source_instagram_media_id using the connected Instagram identity. Mutually exclusive with objectStoryId and fresh creative fields."},"objectStoryId":{"type":"string","pattern":"^\\d+_\\d+$","description":"Messaging and CTWA only. Raw Facebook pageId_postId reference, used as object_story_id even with an Instagram account. Mutually exclusive with existingPostId and fresh creative fields."},"creativeFeatures":{"$ref":"#/components/schemas/MetaCreativeFeatures","description":"Replaces the top-level creativeFeatures map for this item. Omit to inherit; an empty object clears inherited enrollment choices."},"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\n`video`. Required if neither `video` nor an existing post reference is supplied.\n"},"video":{"type":"object","properties":{"url":{"type":"string","format":"uri","description":"Public URL of the video to upload. Provide either `url` or `id`."},"id":{"type":"string","description":"Reuse a video already uploaded to this ad account (list them with GET /v1/ads/videos) instead of re-uploading. Wins over `url`. Provide either `url` or `id`."},"thumbnailUrl":{"type":"string","format":"uri","description":"OPTIONAL: when omitted, the poster is auto-generated\nfrom Meta's own preferred video thumbnail. When Meta\nproduces no candidate the request fails with a 502\nplatform_error (reason: video_thumbnail_unavailable).\n"}},"description":"Video creative. Mutually exclusive with this entry's\n`imageUrl`. Required if neither `imageUrl` nor an existing post reference is supplied.\n"},"welcomeMessage":{"type":"object","required":["text","prefillText"],"properties":{"text":{"type":"string","minLength":1,"description":"Greeting shown when the chat opens. Replaces Meta's default."},"prefillText":{"type":"string","minLength":1,"description":"Message put into the user's text input, ready to send. Replaces Meta's default."}},"description":"Custom chat welcome message for this entry. See the top-level `welcomeMessage` for the single-creative shape."}}}},"adSetId":{"type":"string","description":"Attach the creatives to this EXISTING messaging ad set instead of\nbuilding a campaign, so the ad set keeps its learning phase. It then\nowns budget, targeting and schedule, so `budgetAmount`, `budgetType`,\n`endDate`, `objective`, `countries`, `interests`, `audienceId` and\n`campaignStatus` are rejected with a 400 alongside it. Its\n`destination_type` must match the ad's destination.\n"},"budgetAmount":{"type":"number","exclusiveMinimum":0,"description":"Budget amount in the ad account's currency major units\n(e.g. dollars for USD, not cents). Must be > 0.\nRequired unless `adSetId` is set, where the ad set owns it.\n"},"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\n(e.g. `USD`). Optional: Zernio resolves it from the ad account\nwhen omitted. The value selects the minor-unit exponent Zernio\nconverts budget/bid amounts by before calling Meta (most\ncurrencies are cents; zero-decimal currencies like JPY/KRW are\nsent as-is).\n"},"endDate":{"type":"string","format":"date-time","description":"ISO 8601 datetime. Required when `budgetType` is `lifetime`.\n"},"countries":{"type":"array","items":{"type":"string","minLength":2,"maxLength":2},"description":"ISO 3166-1 alpha-2 country codes. Defaults to `[\"US\"]` only\nwhen no other geo (`cities`, `regions`, `zips`, `metros`,\n`customLocations`) is supplied.\n"},"cities":{"type":"array","description":"City-level geo targeting for local CTWA campaigns. Each entry maps to Meta's\nTargetingGeoLocationCity. `key` is Meta's city ID. `radius`\nand `distance_unit` are coupled: set both or neither.\nMeta enforces a minimum city radius (~17 km / 10 mi);\nsmaller values resolve to a 0-size audience and the ad\nfails at launch. For a tighter catchment use customLocations\n(lat/lng).\n","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\nID (lookupable via GET /v1/ads/targeting/search?type=region).\n","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\npostal id resolved via /v1/ads/targeting/search.\n","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\n(e.g. `DMA:807`).\n","items":{"type":"object","required":["key"],"properties":{"key":{"type":"string","minLength":1},"name":{"type":"string"}}}},"countryGroups":{"type":"array","description":"Meta only. Continents and trade blocs (`geo_locations.country_groups`),\nfor targeting a whole region without listing its countries. Combines\nwith `countries` rather than replacing it, and is also accepted under\n`excludedLocations`. Discoverable via\n`GET /v1/ads/targeting/search?dimension=geo&geoType=country_group`.\n","items":{"type":"string","enum":["africa","asia","europe","north_america","south_america","oceania","central_america","caribbean","eea","euro_area","nafta","mercosur","afta","apec","gcc","cisfta","emerging_markets","itunes_app_store","android_free_store","android_paid_store"]}},"customLocations":{"type":"array","description":"Point-radius geo (Meta `geo_locations.custom_locations`).\nUse for targeting a radius around a specific lat/long when\nno Meta city/region key fits. `distanceUnit` is required.\n","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\nfor automatic placements. When set, restricts delivery to the chosen surfaces,\nmapped onto the ad set's `targeting.{publisher_platforms, facebook_positions, instagram_positions,\nmessenger_positions, audience_network_positions, threads_positions,\nwhatsapp_positions, device_platforms}`. Enum membership is validated here; Meta\nadditionally enforces co-selection rules and restricts which\nplacements are eligible for click-to-WhatsApp ads, returning an actionable\nerror which we surface.\n","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\ntargeting strict; `1` lets Meta expand beyond the supplied\ntargeting when its delivery system finds better matches.\nAlways sent on CREATE (Meta requires it).\n"},"objective":{"type":"string","enum":["OUTCOME_ENGAGEMENT","OUTCOME_SALES","OUTCOME_LEADS"],"description":"Defaults to `OUTCOME_ENGAGEMENT`. `OUTCOME_SALES` and `OUTCOME_LEADS` require\nadditional account configuration (Dataset linked to the WABA\nfor sales) and may be rejected by Meta if missing.\n"},"status":{"type":"string","enum":["ACTIVE","PAUSED"],"description":"Ad-level status. Defaults to `ACTIVE`. `PAUSED` skips activating the\nnewly created ad(s) after Meta accepts them.\n"},"campaignStatus":{"type":"string","enum":["ACTIVE","PAUSED"],"description":"Campaign-level status, same semantics as `POST /v1/ads/create`. Defaults\nto `ACTIVE`. `PAUSED` holds activation at the campaign so it never\nspends before the advertiser reviews it, while the ad set and ad still\nswitch on (one resume call brings the whole hierarchy live). Only\nmeaningful when a new campaign is being created; rejected with a 400\nalongside `adSetId` (the attach shape reuses an existing campaign).\n"},"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\n`LOWEST_COST_WITHOUT_CAP` (auto-bid) when omitted.\n`LOWEST_COST_WITH_BID_CAP` and `COST_CAP` require\n`bidAmount`. `LOWEST_COST_WITH_MIN_ROAS` requires\n`roasAverageFloor`. CTWA's `optimization_goal` is fixed to\n`CONVERSATIONS`, but the bid strategy is independent.\n"},"bidAmount":{"type":"number","exclusiveMinimum":0,"description":"Whole currency units (e.g. `5` = $5.00 on a USD account).\nRequired when `bidStrategy` is `LOWEST_COST_WITH_BID_CAP`\nor `COST_CAP`; rejected otherwise.\n"},"roasAverageFloor":{"type":"number","exclusiveMinimum":0,"description":"Decimal ROAS multiplier (e.g. `2.0` = 2.0× ROAS floor).\nRequired when `bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`;\nrejected otherwise. Meta enforces its own upper bound\nserver-side.\n"},"dsaBeneficiary":{"type":"string","maxLength":100,"description":"Legal entity that benefits from the ad. Required when targeting EU users\n(EU DSA, Article 26). Optional if the ad account has a default beneficiary:\nset it once via `PATCH /v1/ads/accounts` or in Meta Ads Manager, and Meta\nfills it in whenever the field is omitted.\n"},"dsaPayor":{"type":"string","maxLength":100,"description":"Legal entity that pays for the ad. Can differ from `dsaBeneficiary`\n(for example, an agency paying for a client's ads). Same rules as\n`dsaBeneficiary`: required for EU targeting unless the ad account has\na default payor.\n"},"regionalRegulatedCategories":{"type":"array","description":"Meta only. Regional regulation categories required when the ad set targets certain countries (e.g. BRAZIL_REGULATION, SINGAPORE_UNIVERSAL, TAIWAN_UNIVERSAL, THAILAND_UNIVERSAL, AUSTRALIA_FINSERV, INDIA_FINSERV, TAIWAN_FINSERV). Forwarded to the ad set.","items":{"type":"string"}},"regionalRegulationIdentities":{"type":"object","additionalProperties":{"type":"integer"},"description":"Meta only. Beneficiary/payer entity IDs required alongside regionalRegulatedCategories.\nValues are numeric IDs from the advertiser's Meta verification/authorization setup.\nKeys depend on the declared category: BRAZIL_REGULATION and THAILAND_UNIVERSAL use\nuniversal_beneficiary / universal_payer; SINGAPORE_UNIVERSAL uses\nsingapore_universal_beneficiary / singapore_universal_payer; TAIWAN_UNIVERSAL uses\ntaiwan_universal_beneficiary / taiwan_universal_payer; TAIWAN_FINSERV uses\ntaiwan_finserv_beneficiary / taiwan_finserv_payer; AUSTRALIA_FINSERV uses\naustralia_finserv_beneficiary / australia_finserv_payer; INDIA_FINSERV uses\nindia_finserv_beneficiary / india_finserv_payer.\nBoth beneficiary and payer must be included. If omitted and the advertiser has\nset defaults in Meta Ads Manager advertising settings, Meta auto-fills them.\n"}}},"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:\n  messaging (send_message),\n  control flow (trigger, condition, delay, wait_for_reply, a_b_split, end),\n  data ops (set_variable, set_field, add_tag, remove_tag, enroll_sequence),\n  integrations (webhook, ai, handoff, start_call).\n","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).\n\n**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).\n\n**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.\n\n**wait_for_reply**: `{ timeoutMinutes:int (max 43200), saveAs:string }`. Resume via the `'reply'` edge on inbound, or `'timeout'` edge after `timeoutMinutes` of silence.\n\n**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'`.\n\n**set_variable**: `{ assignments:[{ name, value }] }`. Run-scoped (lives only for this execution; use `set_field` for persistent values).\n\n**delay**: `{ delayMinutes:int (max 43200) }`. Suspends the run, resumes via timer.\n\n**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.\n\n**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'`.\n\n**handoff**: `{ note, assignTo }`. Terminates the run as `exited`, flags the conversation for a human operator.\n\n**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'`.\n\n**a_b_split**: `{ percentage: number 0-100 (default 50) }`. Random branch picker. Edges: `'a'` (with probability `percentage/100`), `'b'`.\n\n**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).\n\n**enroll_sequence**: `{ sequenceId, saveAs }`. Enrolls the run's contact into a Sequence. Edges: `'success'`, `'error'`.\n\n**add_tag** / **remove_tag**: `{ tag }`. Push or pull a tag on the Contact. No-op on `api_call` runs.\n\n**end**: no config. Terminates the run as `completed`.\n"},"position":{"type":"object","description":"Canvas coordinates (ignored by the executor; used by the visual builder).","properties":{"x":{"type":"number"},"y":{"type":"number"}}},"label":{"type":"string","minLength":1,"maxLength":80,"description":"Optional display name shown on the builder canvas and inspector, falling back to the node type when absent. The nodes array is replaced wholesale on update, so it must be resent to be kept."}}},"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:\n\n  - **condition**: a rule's `id`, or `'default'` (no rule matched)\n  - **wait_for_reply**: `'reply'` (contact replied) | `'timeout'` (no reply in window)\n  - **webhook**: `'success'` (2xx) | `'error'` (non-2xx / fetch failed)\n  - **ai**: `'success'` (text/JSON response) | `'tool:<toolName>'` (model invoked\n    that tool) | `'error'` (upstream failure / non-JSON in JSON mode)\n  - **start_call**: `'success'` | `'permission_required'` | `'failed'`\n  - **a_b_split**: `'a'` | `'b'`\n  - **enroll_sequence**: `'success'` | `'error'`\n"}}},"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.\n","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:\n  `send_message` → `{ messageType, text, recipient }`,\n  `webhook` → `{ url, method, statusCode, responseTimeMs, responsePreview }`,\n  `ai` → `{ model, provider, inputTokens, outputTokens, responsePreview }`,\n  `condition` → `{ matchedHandle, rulesEvaluated }`,\n  `a_b_split` → `{ percentage, chosen }`.\n"},"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\nsucceeded or all failed) and `207` (mixed). Per-row outcomes live in `results`;\nthe row's success is `ok`, and failures carry machine-readable codes in `errors`.\n","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.\nExamples: `unknown_profile:<id>`, `no_account_for_platform:<platform>`,\n`schedule_time_missing`, `rate_limited:<platform>:@<username>:<remaining>`.\n","items":{"type":"string"}}}}},"warnings":{"type":"array","description":"Top-level advisory warnings, e.g. `rows_exceed_advisory_limit:500` or `unknown_columns:<a,b,c>` (comma-separated unrecognized CSV column names). Empty when none.","items":{"type":"string"}},"rateLimitedAccounts":{"type":"array","description":"Present only when one or more rows targeted an account currently in cooldown.\nLets callers map `rate_limited:*` row errors back to structured metadata without\nparsing the error strings.\n","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\nthrough Zernio). Returned by GET /v1/posts?source=external and\nPOST /v1/posts/sync-external. Analytics are exposed separately via\nGET /v1/analytics?source=external.\n","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)"},"thumbnailUrl":{"type":"string","description":"Thumbnail URL"},"mediaItems":{"type":"array","description":"Per-item media (for carousels / multi-media posts)","items":{"type":"object"}},"mediaProductType":{"type":"string","description":"Instagram only: the platform media product type (e.g. FEED, REELS, STORY, AD). Absent when the platform did not report it."},"isAiGenerated":{"type":"boolean","description":"Instagram only: whether Instagram labeled the media as AI-generated. Absent when the platform did not report it."},"isSharedToFeed":{"type":"boolean","description":"Instagram reels only: whether the reel is also shared to the main feed. Absent when the platform did not report it."},"mediaAudioType":{"type":"string","description":"Instagram only: audio type of the media (MUSIC or ORIGINAL_SOUND). Absent when the platform did not report it."},"analytics":{"type":"object","description":"Engagement + insights for the post. `likes` and `comments` are\navailable immediately after an on-demand sync (they come from the\nplatform listing). `reach`, `impressions`, `views` depend on the\nplatform's insights, which carry their own delay (e.g. ~24h on\nInstagram) and read 0 until the platform makes them available.\n","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","description":"Percentage, rounded to 2 decimals. Same definition as PostAnalytics.engagementRate: (likes + comments + shares + saves) / (impressions or reach or views) * 100, where the denominator is the first of the three that is non-zero. Clicks and follows are never counted."},"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`,\n`code`, `param`, `platform`, and `platformError` are top-level siblings\nfor programmatic handling. For upstream platform failures (`type:\nplatform_error`), `platformError` carries the provider's raw payload\nverbatim (for Meta: `error_subcode`, `error_user_title`, `error_user_msg`).\n","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\nintegrators can read provider-specific codes. For Meta this includes\nerror_subcode, error_user_title, and error_user_msg.\n"},"details":{"type":"object","additionalProperties":true,"description":"Additional structured context (e.g. field-level validation errors), for example\n`privateReplyConsumed` on the private-reply endpoint's 400 when the comment's\nsingle reply is already spent.\n\nOn a Google Ads 429 it carries `quotaExhausted: true`, which marks the failure as\nGoogle's own ads quota rather than a Zernio rate limit, so you can keep calling\nother platforms instead of backing off everywhere. When Google names the scope it\nalso carries `quotaScope`: `DEVELOPER` means the shared developer-token budget\n(every Google account is affected and there is nothing to change on your side),\n`ACCOUNT` means your own ad account. A Meta 429 carries neither field.\n","properties":{"quotaExhausted":{"type":"boolean","description":"Google Ads 429 only. True when the upstream Google Ads quota is spent rather than a Zernio limit."},"quotaScope":{"type":"string","enum":["DEVELOPER","ACCOUNT"],"description":"Google Ads 429 only, when Google names the scope. DEVELOPER is the shared developer-token budget; ACCOUNT is your ad account."}}}}},"CommentAutomationAudience":{"type":"object","description":"Who a comment automation answers. Instagram only - Meta exposes the follow\nrelationship on no other platform, and only for people who have MESSAGED the\naccount (a comment grants no consent). `whenUnknown` is therefore the important\nsetting: it decides what happens for a first-time commenter.\n","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.\n  * `send` (default) - deliver the DM anyway (fails open).\n  * `skip` - stay silent.\n  * `verify` - send `followGate.message` with a confirm button. Tapping it is a\n    message, which grants consent, so the re-check on the tap resolves and the\n    real DM (or `followGate.notFollowingMessage`) follows automatically.\n"}}},"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.\nUp to 3 buttons per automation. `url` and `postback` work on Instagram and\nFacebook; `phone` is Facebook-only. When buttons are set, `dmMessage` becomes\nthe button_template text and must be 640 characters or less.\n","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.\nIt REPLACES the plain `dmMessage` bubble: a Meta message carries one body\nshape, and a comment gets exactly one private reply, so the card and the\ntext cannot both be delivered. Put your selling copy in `subtitle`.\nMutually exclusive with `buttons` (sending both is a 400). Works on both\nthe `comment` and `story_reply` triggers.\nUp to 10 elements, rendered as a horizontally swipeable carousel.\nRendering confirmed on the Instagram and Messenger mobile apps.\n","required":["type","elements"],"properties":{"type":{"type":"string","enum":["generic"]},"imageAspectRatio":{"type":"string","enum":["horizontal","square"],"description":"Facebook only. How Messenger renders each element imageUrl: horizontal (1.91:1, the default) or square (1:1). Instagram has no such setting, so an Instagram automation carrying it is a 400."},"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.\nTransitions `pending → active` when the inbound webhook receives a reply\nfrom the matching phone (the reply itself proves ownership).\n","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\n`active`. Expired sessions are pruned by TTL and never appear in\nlist responses.\n"},"expiresAt":{"type":"string","format":"date-time","description":"UTC timestamp at which the session becomes invalid. Pending sessions\nget a 24h window; activated sessions get 7 days.\n"},"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. Can be empty when YouTube has no retention data for the video in the given range.","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. 0 when YouTube has no segment-level data for the video."},"stoppedWatching":{"type":"integer","description":"Viewers who stopped watching in this segment. 0 when YouTube has no segment-level data for the video."},"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\nanalytics endpoint (/v1/analytics/{facebook|instagram|youtube|linkedin|tiktok}/*).\nThe name is historical - the shape was first shipped for Instagram and every\nnew platform endpoint reuses it for response-shape consistency. The platform\nfield echoes back which platform served the response.\n","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}).\nFor total_value: each metric has \"total\" (number) and optionally \"breakdowns\" (array of {dimension, value}).\n\nMonetary metrics additionally carry \"unit\" and \"currency\". Zernio never rescales money:\n\"total\" and every \"values[].value\" are the platform's raw numbers in the stated unit.\nMonetary metrics also keep \"values\" on metricType=total_value, because their \"total\" is the\nsum of the daily buckets the platform returned over the range: keep the series so you can\nreconcile that sum against the platform's own reporting before invoicing on it.\nA metric that could not be served is absent from this object and listed in\n\"unavailableMetrics\" instead, so an unavailable metric is never reported as a zero.\n","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\",\nexactly as the platform returned them.\n\n\"micro_amount\": the platform returned an object shape carrying a micro amount, and the\nvalues are that integer, summed, unconverted. Zernio does not publish a divisor because\nMeta does not document one; divide by the scale you have verified against the Page's own\nMeta Business Suite export. On Facebook Page insights this is always\ncontent_monetization_earnings.\n\n\"unspecified\": the platform returned a bare number with no unit metadata. It is passed\nthrough as-is; the platform does not state whether it is major or minor currency units.\nOn Facebook Page insights this is always monetization_approximate_earnings.\n"},"currency":{"type":["string","null"],"description":"ISO 4217 currency of a monetary metric, or null when the platform omitted it.\nAlways null on monetization_approximate_earnings, which Meta returns as a bare number\nwith no currency; always present on content_monetization_earnings.\n","example":"USD"}}}},"unavailableMetrics":{"type":"array","description":"Requested metrics that could not be served. Present only when at least one metric is\nunavailable, and absent otherwise. Each listed metric is OMITTED from \"metrics\" rather than\nreported as 0, which is how an unavailable metric is distinguished from a genuine zero.\nThe request itself still succeeds with HTTP 200.\n","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.\n\"permission_missing\": the connected user lacks access to this metric.\n\"unsupported_metric\": the platform does not accept this metric name on the API version Zernio uses.\n\"no_data\": the platform returned no bucket for this metric over the requested range.\n\"unreadable_value\": the platform returned a value shape Zernio cannot read, so no total is reported.\n\"mixed_currency\": readable values disagree on currency or unit within the range.\n\"upstream_error\": any other platform failure.\n\n\"no_data\" is the common case in practice. The others are defensive: \"not_enrolled\" and\n\"unsupported_metric\" in particular have not been observed on live Facebook traffic, since\na non-enrolled Page returns zeros rather than an error and metric names are validated\nbefore any platform call.\n"},"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\nsame unavailable-vs-zero contract as the Page-level response; there is no date range, no\nmetricType, and no daily \"values\", because the single lifetime bucket IS the total.\n","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\nacross dates or across posts.\n"},"metrics":{"type":"object","description":"One entry per served metric. A metric reported here with \"total\": 0 genuinely earned\nnothing (or its Page is not enrolled, which Meta reports identically).\n","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\nthat integer, unconverted. Zernio does not publish a divisor because Meta does not\ndocument one; divide by the scale you have verified against the Page's own Meta\nBusiness Suite export. This is always content_monetization_earnings.\n\n\"unspecified\": Meta returned a bare number with no unit metadata, passed through as-is;\nMeta does not state whether it is major or minor currency units. This is always\nmonetization_approximate_earnings.\n"},"currency":{"type":["string","null"],"description":"ISO 4217 currency, or null when Meta omitted it. Always null on\nmonetization_approximate_earnings; always present on content_monetization_earnings.\n","example":"USD"}}}},"unavailableMetrics":{"type":"array","description":"Requested metrics Meta could not serve. Present only when at least one metric is\nunavailable, and absent otherwise. Each listed metric is OMITTED from \"metrics\" rather than\nreported as 0. The request itself still succeeds with HTTP 200.\n","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.\n\"permission_missing\": the connected user lacks access to this metric.\n\"unsupported_metric\": Meta does not accept this metric name on the API version Zernio uses.\n\"no_data\": Meta returned no bucket for this metric.\n\"unreadable_value\": Meta returned a value shape Zernio cannot read, so no total is reported.\n\"mixed_currency\": readable values disagree on currency or unit.\n\"upstream_error\": any other platform failure.\n\n\"no_data\" is the common case in practice; the others are defensive.\n"},"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","analytics.synced","message.received","conversation.started","conversation.control_changed","call.received","call.ended","call.failed","call.permission_request","message.sent","message.edited","message.deleted","message.delivered","message.read","message.failed","reaction.received","referral.received","comment.received","review.new","review.updated","lead.received","ad.status_changed","whatsapp.template.status_updated","whatsapp.template.category_updated","whatsapp.account.name_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","phone_number.stock_available","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 terminal delivery failures (resets to 0 on any successful delivery). Auto-disable only triggers when the endpoint has had no successful delivery within a 3-day window AND either reaches 20 consecutive terminal failures or has been failing continuously for 3 days; any success within that window keeps the endpoint enabled regardless of the count."},"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: the payload `id`, also sent as the X-Zernio-Event-Id header. Shared by every attempt and redelivery of the same event."},"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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"WebhookPayloadPostPlatform":{"type":"object","description":"Webhook payload for the per-platform terminal events\n`post.platform.published` and `post.platform.failed`, for\n`post.platform.deleted` (same shape, fired when Zernio's background\nsync detects that a platform target published through Zernio was\nlater deleted on the platform; poll-driven ~hourly, not real-time),\nand for `post.tiktok.url_resolved` (same shape, fired when a\npublished TikTok post's public URL is backfilled). Terminal events\nfire once per platform target inside a post as that platform\nreaches a terminal state (published or permanent failure), except\nthat a target which later fails background reconciliation emits\n`post.platform.failed` a second time, after its own\n`post.platform.published`. The `post` envelope mirrors the shape\nof `WebhookPayloadPost` so\nconsumers can reuse rendering logic; the `platform` block\nidentifies which specific platform transitioned; the `account`\nblock identifies the connected account behind that\nplatform-write.\n","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`\nif other platforms haven't terminated; check this field\nrather than assuming.\n"},"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 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 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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"PhoneNumberStockWatch":{"type":"object","required":["id","country","countryName","numberType","createdAt"],"properties":{"id":{"type":"string"},"country":{"type":"string","description":"ISO 3166-1 alpha-2."},"countryName":{"type":"string"},"numberType":{"type":["string","null"],"enum":["local","mobile","national","toll_free",null],"description":"The watched number type, or null when the watch covers every type in the country."},"areaCode":{"type":["string","null"],"description":"The watched area code (NDC), or null when the watch covers every area."},"createdAt":{"type":"string","format":"date-time"}}},"WebhookPayloadPhoneNumberStockAvailable":{"type":"object","description":"Webhook payload for phone_number.stock_available events","required":["id","event","stock","timestamp"],"properties":{"id":{"type":"string","description":"Stable webhook event ID"},"event":{"type":"string","enum":["phone_number.stock_available"]},"stock":{"type":"object","required":["country","types"],"properties":{"country":{"type":"string","description":"ISO 3166-1 alpha-2 country code of the watched country."},"types":{"type":"array","description":"Number types deliverable at sweep time. Only types with stock are listed.","items":{"type":"object","required":["numberType","availableCount"],"properties":{"numberType":{"type":"string","description":"local, mobile, national or toll_free."},"availableCount":{"type":"integer","description":"Deliverable numbers at sweep time; first come, first served."}}}},"areaCode":{"type":"string","description":"Set when the watch named an area: the area code (NDC) that is back in stock."},"areaName":{"type":"string","description":"The name of that area, when known."}}},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"WebhookPayloadAccountAdsInitialSyncCompleted":{"type":"object","description":"Webhook payload for `account.ads.initial_sync_completed` events.\nFired once per ads-enabled account when the initial discovery + 90-day\nad backfill finishes (whether it succeeded fully, partially, or failed).\n","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\nthat ID back so the webhook can be correlated to the originating connect\nrequest without consulting the consumer's DB. Meta uses the `act_*` shape.\n","example":"act_1330190928038136"},"platformAdAccountIds":{"type":"array","description":"Every ad-account ID that the connected token could see at discovery time.\nUseful for \"we synced ads from these accounts\" UX without a follow-up API call.\nEmpty array when the token had no ad-account visibility.\n","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).\nTruncated to ~2KB. Present when `status` is `failure` (and sometimes on `success`\nwhen discovery saw zero ad accounts). For UX branching prefer `errorCategory`;\nthis field is for human display and debugging.\n"},"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\nstable. Mapping:\n  - `token_invalid`: access token is expired or revoked. Reconnect.\n  - `permission_denied`: token lacks required scope, or the user has no role\n    on the Business Manager that owns the ad account. Reconnect with full\n    permissions, or have an admin grant access.\n  - `no_ad_accounts`: token is valid but sees zero ad accounts. The user\n    needs to connect a Business Manager that owns ad accounts.\n  - `rate_limited`: platform throttled us. Sync will retry automatically.\n  - `discovery_failed`: any other platform-side failure. Inspect `error`.\n  - `unknown`: classifier could not categorize the failure.\n"}}},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"WebhookPayloadAnalyticsSynced":{"type":"object","description":"Webhook payload for `analytics.synced`. Fired once per connected account each\ntime its analytics sync cycle completes successfully. Poll-driven (roughly\nhourly per account), not real-time, and never fired for a skipped or failed\ncycle.\n\nA TRIGGER, not a transport: it deliberately carries no metrics and no cursor.\nWhen it arrives, call `GET /v1/analytics/delta` with YOUR OWN last `nextCursor`\nto read what changed, across every account, in one paginated stream.\n\nThe absent cursor is deliberate. The feed's ordering position is assigned inside\nthe analytics store when the row is materialized, which normally has not\nhappened yet at the moment this event fires, so a cursor minted here could sit\nahead of the very rows the event announces and make you skip them. Your own\n`nextCursor` is always in the feed's own ordering and can never do that.\n\nBecause of that same lag, a delta read issued the instant this event lands can\nlegitimately come back empty. That is not \"nothing changed\": poll again with the\nsame cursor you last used rather than treating the account as done.\n\nSubscribe to this event on a DEDICATED webhook endpoint. It is high volume\n(roughly one delivery per connected account per hour) and a subscription's\nconsecutive-failure count is shared across all of its events, so an outage while\nthis event is flowing can suppress the low-volume publishing events that share\nthe same subscription.\n","required":["id","event","account","sync","timestamp"],"properties":{"id":{"type":"string","description":"Stable webhook event ID"},"event":{"type":"string","enum":["analytics.synced"]},"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 this account belongs to"},"platform":{"type":"string","example":"youtube"},"username":{"type":"string"}}},"sync":{"type":"object","description":"Summary of the analytics sync cycle that completed.","required":["syncedAt","postsUpdated"],"properties":{"syncedAt":{"type":"string","format":"date-time","description":"When the cycle COMPLETED. Not a join key for the delta feed: the rows a\ncycle produces carry a `syncedAt` stamped when the cycle STARTED, which\nis measured at around one second earlier at the median and up to a\ncouple of minutes earlier in the tail. Correlate on `account.accountId`.\n"},"postsUpdated":{"type":"integer","description":"Post records created or modified by this cycle. Not the number of delta\nfeed rows the cycle produced, which the syncer does not report, so a\ncycle with a non-zero `postsUpdated` can still yield an empty delta page.\n","example":42}}},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued)."}}},"WebhookPayloadComment":{"type":"object","description":"Webhook payload for comment received events (Instagram, Facebook, Threads, YouTube, LinkedIn, Bluesky, Reddit, TikTok). X/Twitter does NOT fire this event. TikTok events carry only the author id: the comment.update webhook has no username, picture or owner flag.","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","threads","youtube","linkedin","bluesky","reddit","tiktok"]},"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"]},"isOwnAccount":{"type":"boolean","description":"True when this comment was authored by the connected account itself (Meta re-delivers the account's own replies as comments events). Populated on the Instagram and Facebook realtime webhooks only; absent means not evaluated, never \"not the account\"."},"instagramProfile":{"type":"object","description":"Instagram only, best-effort. Present ONLY for commenters who have\nmessaged the account before: Meta gates the follow relationship behind\nmessaging consent, and commenting does not grant it. Absent otherwise -\ntreat a missing object as \"unknown\", never as \"not a follower\". To check\non demand, call GET /v1/accounts/{accountId}/follow-status/{userId}.\n","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.\nInstagram: populated from the webhook payload's value.media.ad_id and value.media.ad_title.\nFacebook: populated via a Graph API lookup of the parent post's promotion_status.\nAbsent for comments on organic posts that are not currently promoted.\n","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:\n\"active\" (organic post currently boosted), \"ineligible\" (dark\npost or ad creative, not promotable because it already is an ad).\n"}}},"attachment":{"type":"object","description":"Facebook only. Present on graphic-only comments (sticker, GIF, photo) that\ncarry no text. URLs are ephemeral and may expire for Meta platforms (oe= expiry),\nso fetch promptly. Instagram comments do not support attachments.\n","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","content","imageUrl","permalink"],"properties":{"id":{"type":["string","null"],"description":"Internal post ID (null for posts not published through Zernio)"},"platformPostId":{"type":"string","description":"Platform's post ID"},"content":{"type":["string","null"],"description":"Post text, from our synced copy. No platform call is made on the comment path, so null when the post was never synced."},"imageUrl":{"type":["string","null"],"description":"Post thumbnail or first media item URL. Platform CDN URLs expire, fetch promptly."},"permalink":{"type":["string","null"],"description":"Public URL of the post. Null when no URL was ever stored for it, for example a platform draft or a post recovered without one."}}},"account":{"type":"object","required":["id","platform","username"],"properties":{"id":{"type":"string","description":"Account ID"},"accountId":{"type":"string","description":"Account ID (same as id); canonical field for account filtering."},"platform":{"type":"string"},"username":{"type":"string"}}},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"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.\n"},"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":"Account ID (the facebook account owning the Page)"},"accountId":{"type":"string","description":"Account ID (same as id); canonical field for account filtering."},"platform":{"type":"string","enum":["facebook"]}}},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"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 Profile)."},"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 Profile 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":"Account ID (same as id); canonical field for account filtering."},"platform":{"type":"string"},"username":{"type":"string"}}},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"WebhookPayloadReviewUpdated":{"type":"object","description":"Webhook payload for the review.updated event. Fired when the reviewer edits their\ntext or rating, or when a reply is posted through POST /v1/inbox/reviews/{reviewId}/reply.\nA reply written\ndirectly in Google's own interface does NOT fire this event: Google emits no\nnotification when a reviewReply is written. Same shape as review.new. When a reply\nis present, review.hasReply is true and review.reply is populated.\n","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":"Account ID (same as id); canonical field for account filtering."},"platform":{"type":"string"},"username":{"type":"string"}}},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"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 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"},"mediaProductType":{"type":"string","description":"Instagram only: the platform media product type (e.g. FEED, REELS, STORY, AD). Absent when the platform did not report it."},"isAiGenerated":{"type":"boolean","description":"Instagram only: whether Instagram labeled the media as AI-generated. Absent when the platform did not report it."},"isSharedToFeed":{"type":"boolean","description":"Instagram reels only: whether the reel is also shared to the main feed. Absent when the platform did not report it."},"mediaAudioType":{"type":"string","description":"Instagram only: audio type of the media (MUSIC or ORIGINAL_SOUND). Absent when the platform did not report it."},"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 /\npost.external.deleted. Fired by Zernio's background sync when it detects a\nnatively-authored post (e.g. a Google Business Profile localPost created in\nthe Google UI), NOT a post published through Zernio. Poll-driven (~hourly),\nnot real-time. On post.external.deleted, post.deletedAt is populated.\n","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":"Account ID (same as id); canonical field for account filtering."},"platform":{"type":"string"},"username":{"type":"string"}}},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"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","sms"]},"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\nplatform: inbound WhatsApp media points at the authenticated\n`GET /v1/whatsapp/media/{mediaId}` and requires\n`Authorization: Bearer <your API key>`, while outgoing media carries the\nURL originally supplied and Instagram / Facebook / Telegram carry direct\nplatform CDN links that need no authentication.\n"},"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\n(without leading `+`) when available, otherwise the `businessScopedUserId`.\nFor other platforms, the platform's own user ID.\n"},"contactId":{"type":"string","description":"Zernio CRM Contact id for this sender, when one exists (joined via\nthe ContactChannel mapping). Lets integrators link a message straight\nto a Contact without a follow-up Contacts API call. Omitted when the\nsender isn't a tracked contact (e.g. outgoing messages where the\nsender is the business, or first-touch messages before the contact\nis created).\n"},"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 `+`).\n\n**Nullable during the BSUID rollout (April 2026+).** WhatsApp users\nwho adopt a username can message businesses without exposing a phone\nnumber, so this field is omitted for them. Match by `businessScopedUserId`\ninstead. See `docs/whatsapp-bsuid-migration.md`.\n"},"businessScopedUserId":{"type":"string","description":"WhatsApp only. Business-scoped user ID (BSUID), Meta's canonical\nidentifier for a WhatsApp user within your business. Present when\nMeta includes it in the inbound payload (rollout in progress since\nearly April 2026). **Recommended primary identity anchor** going\nforward; fall back to `phoneNumber` only when this field is absent.\n"},"parentBusinessScopedUserId":{"type":"string","description":"WhatsApp only. Parent BSUID for businesses with linked business\nportfolios. Omitted for standalone portfolios.\n"},"whatsappUsername":{"type":"string","description":"WhatsApp only. User's WhatsApp username (e.g. `@jane`). Not a\nstable identifier, because users can change it. Useful for display, not\nrecommended as an identity anchor.\n"},"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","description":"When the message was sent, as reported by the platform and passed through unmodified. Full ISO 8601 date-time: Instagram and Facebook carry millisecond precision, while some platforms (for example WhatsApp and Telegram) report whole seconds. Use this field as the chronological ordering key. If two messages share the same value, fetch the conversation messages with sortOrder=desc for the deterministic order."},"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\njoining `participantId` to the ContactChannel collection. Best-effort:\nomitted when no channel matches or `participantId` is absent. Lets\nintegrators join any inbox webhook back to the CRM Contact without\nneeding to look at the sender, which matters for outgoing and\ndelivery-status events whose sender is the business.\n"}}},"InboxWebhookConversationDetail":{"type":"object","description":"The conversation object included in conversation lifecycle webhook payloads (conversation.started, conversation.control_changed).","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\njoining `participantId` to the ContactChannel collection (same join\nused by message.*, reaction.received, and call.* webhooks). Best-effort:\nomitted when no channel matches or `participantId` is absent. Lets\nintegrators seed the CRM straight from `conversation.started` without\nwaiting for the first `message.*` event.\n"}}},"InboxWebhookAccount":{"type":"object","description":"The account context included in inbox webhook payloads.","required":["id","platform","username"],"properties":{"id":{"type":"string","description":"Account ID"},"accountId":{"type":"string","description":"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 ID this account belongs to. Use it to route or filter inbox webhooks by profile. 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\n`removed` on WhatsApp (Meta does not report which emoji was removed).\n"},"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 WhatsApp, Slack, Instagram and Facebook Messenger it is the business own platform id when the business reacted from the native app or via the reactions API: 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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"WebhookPayloadReferral":{"type":"object","description":"Webhook payload for referral received events (Instagram, Facebook Messenger)","required":["id","event","referral","sender","conversation","account","timestamp"],"properties":{"id":{"type":"string","description":"Stable webhook event ID"},"event":{"type":"string","enum":["referral.received"]},"referral":{"type":"object","description":"Meta's referral object, forwarded verbatim. Same shape as\n`metadata.referral` on `message.received`: `ref` + `source` for\nig.me / m.me links, `ad_id` + `ads_context_data` for returning\nMessenger ad clicks.\n","properties":{"ref":{"type":"string","description":"The `ref` parameter of the clicked ig.me / m.me link or ad."},"source":{"type":"string","description":"Meta-supplied source (`SHORTLINK`, `SHORTLINKS`, `IGME-SOURCE-LINK`, `ADS` - treat as opaque)."},"type":{"type":"string","description":"Meta-supplied referral type (e.g. `OPEN_THREAD`)."},"referer_uri":{"type":"string","description":"URI of the originating site, when Meta supplies one. Facebook Messenger only."},"ad_id":{"type":"string","description":"The Meta ad ID, on returning ad clicks. Facebook Messenger only."},"ads_context_data":{"type":"object","description":"Snapshot of the ad's public context at click time.","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"}}}}},"sender":{"type":"object","required":["id"],"description":"Who clicked - the conversation participant.","properties":{"id":{"type":"string","description":"Platform-scoped user ID (IGSID / PSID)."},"contactId":{"type":"string","description":"Zernio CRM Contact id for this sender, when one exists."}}},"conversation":{"$ref":"#/components/schemas/InboxWebhookConversation"},"account":{"$ref":"#/components/schemas/InboxWebhookAccount"},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"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","sms"]},"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, share)"},"originalType":{"type":"string","description":"Instagram and Facebook only, and present only when it differs\nfrom `type`. Meta's own attachment type before Zernio normalized\nit: `ig_reel` and `reel` become `video`, while `ig_post`, `post`,\n`ig_story` and `story_mention` all become `share`.\n\nRead it before rendering, because `type: \"share\"` alone is\nambiguous. In particular a story mention arrives as\n`type: \"share\"` with `originalType: \"story_mention\"`; treating an\nunrecognized type as a generic document shows your agent\n\"document received\" for what is usually a lead.\n"},"url":{"type":"string","description":"Where to fetch the attachment. **The contract differs by platform.**\n\n- **WhatsApp**: points at `GET /v1/whatsapp/media/{mediaId}`, an\n  authenticated Zernio endpoint. You MUST send\n  `Authorization: Bearer <your API key>`; fetching it without that\n  header returns `401`. Download and store the bytes when this\n  webhook arrives: Meta drops inbound media after a limited\n  retention window, after which the endpoint answers `400`\n  permanently and the media is unrecoverable.\n- **Instagram / Facebook / Telegram**: a direct platform CDN link\n  that needs no authentication and expires on the platform's own\n  schedule.\n\n**Webhook attachments carry no `refreshUrl`.** That field is\nstamped only when you read a message back over REST\n(`GET /v1/inbox/conversations/{conversationId}/messages`). On\nInstagram and Facebook the url above is a signed Meta CDN link\nthat expires, so do not persist it: store the message id and\nresolve the media through\n`GET /v1/inbox/conversations/{conversationId}/messages/{messageId}/attachments/{index}?accountId={accountId}`,\nwhich re-mints it on demand. Every value that URL needs is\nalready in this payload: `message.conversationId`,\n`message.platformMessageId`, `account.accountId`, and the\nattachment's zero-based position in this array.\n"},"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\nnumber (without leading `+`) when available, otherwise the\n`businessScopedUserId`.\n"},"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 `+`).\n\n**Nullable during the BSUID rollout (April 2026+).** WhatsApp\nusers who adopt a username can message businesses without\nexposing a phone number, so this field is omitted for them.\nMatch by `businessScopedUserId` instead. See\n`docs/whatsapp-bsuid-migration.md`.\n"},"businessScopedUserId":{"type":"string","description":"WhatsApp only. Business-scoped user ID (BSUID), Meta's canonical\nidentifier for a WhatsApp user within your business. Present\nwhen Meta includes it in the inbound payload (rollout in\nprogress since early April 2026). **Recommended primary identity\nanchor** going forward; fall back to `phoneNumber` only when\nthis field is absent.\n"},"parentBusinessScopedUserId":{"type":"string","description":"WhatsApp only. Parent BSUID for businesses with linked business\nportfolios. Omitted for standalone portfolios.\n"},"whatsappUsername":{"type":"string","description":"WhatsApp only. User's WhatsApp username (e.g. `@jane`). Not a\nstable identifier, because users can change it. Useful for display,\nnot recommended as an identity anchor.\n"},"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","description":"When the message was sent, as reported by the platform and passed through unmodified. Full ISO 8601 date-time: Instagram and Facebook carry millisecond precision, while some platforms (for example WhatsApp and Telegram) report whole seconds. Use this field as the chronological ordering key. If two messages share the same value, fetch the conversation messages with sortOrder=desc for the deterministic order."},"isRead":{"type":"boolean"},"sentVia":{"type":["string","null"],"enum":["human","api","broadcast","sequence","workflow","comment_automation","bulk-api",null],"description":"Which Zernio surface produced the message. Always present and\nalways `null` on this event, since nobody on our side produced an\ninbound message; it is only informative on `message.sent`, which\ndocuments the vocabulary.\n"}}},"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, a quote-reply to an earlier message, or a WhatsApp inbound that Meta Business Agent is answering)","properties":{"standby":{"type":"boolean","description":"WhatsApp only. true when this inbound arrived while Meta Business Agent held the conversation: the agent answers it, and Zernio only observes. Sending a reply takes control back. See conversation.control_changed."},"quotedMessageId":{"type":"string","description":"Raw platform envelope id (WhatsApp `context.id`; Instagram and\nFacebook Messenger `reply_to.mid`) of the message this one is a\nquote-reply to, forwarded verbatim. It may not equal the stored\nid of that message (see `quotedMessage.platformMessageId`). On\noutgoing messages the same field appears on `message.sent`, but\nonly on some surfaces: see\nWebhookPayloadMessageSent.metadata.quotedMessageId.\n"},"quotedMessage":{"type":"object","description":"Zernio's own ids for the message this one quote-replies to. Present only when that message is stored; WhatsApp only today.","properties":{"messageId":{"type":"string","description":"Internal id of the stored quoted message."},"platformMessageId":{"type":"string","description":"The STORED message's platform id (what message.sent and list-messages return). Can differ from quotedMessageId, because Meta renders one message under a different wamid per perspective."}}},"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:\n`button_reply` (tap on an interactive button), `list_reply` (tap on a\nlist row), or `nfm_reply` (a WhatsApp Flow submission or an\n`address_message` submission, see `nfmReplyName`).\n"},"interactiveId":{"type":"string","description":"WhatsApp only. The `id` of the tapped button or list row, matching the\n`id` you supplied when the message was sent. Not set for Flow responses.\n"},"buttonPayload":{"type":"string","description":"WhatsApp only. Payload attached to a tapped template button. Template\nbuttons emit a plain `button` webhook (not an interactive reply), so\n`interactiveType` is empty while this field is populated.\n"},"flowResponseJson":{"type":"string","description":"WhatsApp only. Raw `nfm_reply.response_json` string returned by a\nFlow submission. Useful if you need the exact wire payload; for\ntyped access use `flowResponseData` instead.\n"},"flowResponseData":{"type":"object","additionalProperties":true,"description":"WhatsApp only. Parsed Flow response JSON. Populated when\n`flowResponseJson` is valid JSON; otherwise omitted. Keys and\nvalue types depend on the specific Flow that was submitted. An\n`address_message` submission (`nfmReplyName: address_message`) carries\nthe address fields (`name`, `address`, `city`, `state`, `in_pin_code`,\n...), either at the top level or nested under `values`; read both.\n"},"nfmReplyName":{"type":"string","description":"WhatsApp only. `nfm_reply.name` as Meta sent it, e.g. `flow` or\n`address_message`. Address submissions share the `nfm_reply`\nenvelope with Flow submissions and are otherwise indistinguishable\nin `flowResponseData`; use this field to tell them apart.\n"},"order":{"type":"object","description":"WhatsApp only. Cart submitted by the user from a commerce message\n(catalog, product, or product-list message). Meta's `order` object\nforwarded verbatim.\n","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\ninbound text carries Meta's `context.referred_product` (the user\ntapped \"Message business\" on a product). Forwarded verbatim.\n","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."}}},"location":{"type":"object","description":"WhatsApp only. The location pin the user shared, forwarded\nverbatim from Meta. The message `text` is only the emoji preview\n(`📍 <name>`); the coordinates live here.\n","properties":{"latitude":{"type":"number","description":"Latitude in decimal degrees."},"longitude":{"type":"number","description":"Longitude in decimal degrees."},"name":{"type":"string","description":"Location name, when the user shared a named place."},"address":{"type":"string","description":"Street address, when Meta sends one."}}},"contacts":{"type":"array","description":"WhatsApp only. Contact cards the user shared, forwarded verbatim\nfrom Meta. Read `contactsOrigin` before treating any number here\nas the sender's own.\n","items":{"type":"object","additionalProperties":true}},"contactsOrigin":{"type":"string","enum":["contact_request","other"],"description":"WhatsApp only. How the contact card was shared.\n`contact_request` means the user tapped a `request_contact_info`\nbutton, so the number is their own and consented. `other` means\nthey picked a card from their address book: it may be anyone's,\nand must NOT be stored as the sender's identity. Omitted when\nMeta sends no origin.\n"},"storyReply":{"type":"object","description":"Instagram only. Populated when an IG user replies to one of the\naccount's stories (Meta `messaging_story_replies`). Mutually\nexclusive in practice with `isStoryMention`.\n","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\n24 hours after the story posted; consumers must fetch\npromptly or treat 404s as expected.\n"}}},"isStoryMention":{"type":"boolean","description":"Instagram only. True when the message was generated by an IG\nuser mentioning the account in their own story (`story_mention`\nattachment type). Mutually exclusive in practice with `storyReply`.\n"},"referral":{"type":["object","null"],"description":"Click attribution forwarded verbatim from Meta. Populated only on\nthe FIRST inbound message after the click; absent on subsequent\nmessages of the same conversation. On Instagram and Messenger a\nRETURNING click also attaches it to the first message that\nfollows, so read it on every `message.received` for per-click\nattribution; a click that opens an existing thread WITHOUT a\nmessage arrives as the separate `referral.received` event.\n\nThe populated subset identifies the source:\n  - `ctwa_clid` and `source_*` fields: WhatsApp CTWA\n    (Click-to-WhatsApp). Attribution window is 7 days from click.\n    Forward to Meta Conversions API for Business Messaging replay.\n  - `ad_id` and `ads_context_data`: Facebook Messenger CTM\n    (Click-to-Message) or Instagram CTD (Click-to-Direct). Use\n    `ad_id` to attribute the conversation to a specific ad.\n  - `ref` without `ad_id`: an ig.me / m.me link carrying a\n    `?ref=` parameter (`source` is `SHORTLINK`, `SHORTLINKS` or\n    `IGME-SOURCE-LINK` depending on surface - treat it as\n    opaque). Instagram delivers ig.me refs on new threads only\n    when the account has at least one Ice Breaker configured\n    (`PUT /v1/accounts/{accountId}/instagram-ice-breakers`).\n","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\nuser clicked to start the conversation.\n"},"ref":{"type":"string","description":"The `ref` parameter passed through from the Meta ad creative\nor from an ig.me / m.me link. Instagram / Facebook Messenger\nonly.\n"},"source":{"type":"string","description":"Meta-supplied source identifier (`ADS` for ad clicks;\n`SHORTLINK`, `SHORTLINKS` or `IGME-SOURCE-LINK` for ref\nlinks). Instagram / Facebook Messenger only.\n"},"type":{"type":"string","description":"Meta-supplied referral type (e.g. `OPEN_THREAD`). Instagram /\nFacebook Messenger only.\n"},"referer_uri":{"type":"string","description":"URI of the originating site, when Meta supplies one (m.me\nlinks opened from the web). Facebook Messenger only.\n"},"ads_context_data":{"type":"object","description":"Snapshot of the ad's public context at click time. Facebook\nMessenger CTM / Instagram CTD only.\n","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"}}}}},"unsupported":{"type":"object","description":"WhatsApp only. Meta's own reason this message has no\nrenderable body. Present when Meta attached an error to the\ninbound payload; in practice the `unsupported`, `errors` and\n`unknown` types (code 131051: message type currently not\nsupported). `text` on those messages is the fixed\n`[Unsupported message]` placeholder.\n","properties":{"code":{"type":"integer","description":"Meta's numeric error code (e.g. 131051)."},"title":{"type":"string","description":"Meta's short error title."},"details":{"type":"string","description":"Meta's human-readable error detail string."}}},"noRenderableContent":{"type":"boolean","description":"Instagram / Facebook Messenger only. Set when the message\ncarries nothing an integrator can render (a `template`\nattachment with no text and no parseable content, or Meta's\nown `is_unsupported` flag). Sibling of `unsupported` above\n(WhatsApp only, carries Meta's error code/title/details): this\nfield has no error envelope, only the boolean. Absence means\n\"not flagged\", never \"checked and renderable\".\n"}}},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"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","twitter","reddit","bluesky","slack"],"description":"Every platform whose outgoing messages Zernio observes. sms is absent on purpose: its carrier receipts update delivery status and never raise message.sent."},"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, share)"},"originalType":{"type":"string","description":"Instagram and Facebook only, and present only when it differs from `type`. Meta's own attachment type before Zernio normalized it. See the same field on message.received for the full mapping."},"url":{"type":"string","description":"Where to fetch the attachment. For outgoing messages this is the\nmedia URL as sent, so for WhatsApp it is the URL you supplied when\npublishing (WhatsApp sends media by link), not a Zernio endpoint,\nand it needs no Zernio credentials. Contrast the inbound direction:\n`message.received` attachment URLs on WhatsApp point at the\nauthenticated `GET /v1/whatsapp/media/{mediaId}`.\n\nAs on `message.received`, webhook attachments carry no\n`refreshUrl`: that field is stamped only on the REST read. Resolve\nInstagram and Facebook media through\n`GET /v1/inbox/conversations/{conversationId}/messages/{messageId}/attachments/{index}?accountId={accountId}`.\n"},"payload":{"type":"object","description":"Additional attachment metadata"}}}},"sender":{"type":"object","required":["id"],"description":"**On this event the sender is your own business, not the person you\nare talking to.** `id` is the Zernio account id and `name`,\n`username` and `picture` are that connected account's own profile.\n\nDo not read these to name or update a contact: doing so on an echo\nrelabels the customer's record with your business name. The other\nparty is `conversation.participantId` / `participantName` /\n`participantUsername`, which are populated in both directions.\n","properties":{"id":{"type":"string","description":"The Zernio account id of the connected account that sent the message, not a contact id."},"contactId":{"type":"string","description":"Always omitted on this event: the sender is the business, not a contact. Use conversation.contactId to join back to the CRM Contact."},"name":{"type":"string","description":"Display name of your connected account."},"username":{"type":"string","description":"Username of your connected account."},"picture":{"type":"string","description":"Profile picture of your connected account."}}},"sentAt":{"type":"string","format":"date-time","description":"When the message was sent, as reported by the platform and passed through unmodified. Full ISO 8601 date-time: Instagram and Facebook carry millisecond precision, while some platforms (for example WhatsApp and Telegram) report whole seconds. Use this field as the chronological ordering key. If two messages share the same value, fetch the conversation messages with sortOrder=desc for the deterministic order."},"isRead":{"type":"boolean"},"source":{"type":"string","enum":["whatsapp_business_app","cloud_api","meta_business_agent"],"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); meta_business_agent when Meta Business Agent answered on the number. Absent on non-WhatsApp platforms. Says where WhatsApp saw the send come from, not which Zernio surface produced it: read sentVia for that."},"sentVia":{"type":["string","null"],"enum":["human","api","broadcast","sequence","workflow","comment_automation","bulk-api",null],"description":"Which Zernio surface produced this message: `human` (an operator\nin the Zernio inbox), `api` (a call to this API), `broadcast`,\n`sequence`, `workflow`, `comment_automation`, or `bulk-api`\n(POST /v1/whatsapp/bulk). Same vocabulary as the `source` filter\non the inbox analytics endpoints, and the same value a later\nGET on this message returns.\n\nAlways present, and `null` whenever the lineage is unknown: a\nmessage sent from the platform's own app, and every message\nstored before this field shipped (2026-08). Existing messages\nare NOT backfilled, so treat `null` as \"unknown\", never as\n\"sent by a human\".\n"}}},"conversation":{"$ref":"#/components/schemas/InboxWebhookConversation"},"account":{"$ref":"#/components/schemas/InboxWebhookAccount"},"metadata":{"type":"object","description":"Platform-specific context for the sent message: a quote-reply reference, a WhatsApp location pin or WhatsApp contact cards. The key is present only when the send carried some context, and absent otherwise: it is never null and never an empty object. Read it to tell a location or contact-card message from a text one without a GET on the message.","properties":{"location":{"type":"object","description":"WhatsApp only. The location pin this message carries, in the same\nshape the inbox send API accepts. Present on API sends that passed\n`location`, and on Coexistence echoes of a pin shared from the\nWhatsApp Business app. The message `text` is only the emoji\npreview (`📍 <name>`); the pin itself lives here.\n","properties":{"latitude":{"type":"number","description":"Latitude in decimal degrees."},"longitude":{"type":"number","description":"Longitude in decimal degrees."},"name":{"type":"string","description":"Location name, when one was given."},"address":{"type":"string","description":"Street address, when one was given."}}},"contacts":{"type":"array","description":"WhatsApp only. The contact cards this message carries. On API\nsends this is the `contacts` array exactly as given to the inbox\nsend API (`name`, `phones[].phone` / `type`, `emails[]`); on\nCoexistence echoes of a card shared from the WhatsApp Business\napp it is Meta's shape (`phones[].wa_id`, `vcard`). The message\n`text` is only the emoji preview (`👤 <name>`); the cards live\nhere.\n","items":{"type":"object","additionalProperties":true}},"quotedMessageId":{"type":"string","description":"`platformMessageId` of the message this send is a quote-reply to.\n\nPresent when the reply was sent through Zernio with `replyTo` on\nthe inbox send API (WhatsApp and Telegram). A WhatsApp API send\nfires its `message.sent` off the delivery status, and the quote\nreference is forwarded from the stored send there, so it arrives\non the same `message.sent` as any other WhatsApp send.\n\nNot delivered on Instagram echoes. Zernio forwards\n`reply_to.mid` whenever Meta puts it on an echo, but on\nInstagram Meta does not send it, so a reply the operator quoted\nin the Instagram app arrives with no `quotedMessageId`.\nFacebook Messenger rides a separate subscription\n(`message_echoes`) and has not been measured, so treat it as\nunverified rather than supported.\n\nAbsent on WhatsApp Coexistence echoes. Meta omits the quote\ncontext from `smb_message_echoes`, so a reply the operator sent\nfrom the WhatsApp Business app arrives with no `quotedMessageId`\neven though WhatsApp shows it as a quote-reply. Do not read the\nabsence of this field as \"not a reply\".\n"},"threadTs":{"type":"string","description":"Slack only. Parent thread ts of the sent message. Pass it back as\n`replyTo` on the inbox send API to keep replying inside the thread.\n"}}},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"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\nedits a previously-sent message. Supported platforms: Instagram,\nFacebook Messenger, Telegram, WhatsApp. The message object reflects the\nLATEST state; editHistory contains every prior version in order\n(oldest first), so the last entry is the version immediately before\nthe current content.\n","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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"WebhookPayloadMessageDeleted":{"type":"object","description":"Webhook payload for message.deleted events. Fires when the sender\ndeletes (unsends) a message. Supported platforms: Instagram (incoming\nunsend) and WhatsApp, in both directions: an outgoing message the\nbusiness deleted (via the Cloud API, or from the WhatsApp Business app\non a Coexistence number) and an incoming message the customer deleted.\nRead `message.direction` to tell the two apart.\n\nThe message.text and message.attachments fields retain the content\nthat existed before the delete. The Zernio dashboard UI does not show\nthis content, but authorized API consumers may access it for\nmoderation, compliance, or archival use cases.\n","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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"WebhookPayloadMessageDeliveryStatus":{"type":"object","description":"Shared payload for message.delivered, message.read, and\nmessage.failed events. Fires when the platform reports a new\ndelivery state for an outgoing message.\n\nPlatform support:\n  * message.delivered: WhatsApp, Facebook Messenger, SMS.\n  * message.read: WhatsApp, Facebook Messenger, Instagram. Not SMS\n    (carriers report delivery, never read).\n  * message.failed: WhatsApp and SMS (other platforms don't expose\n    per-message failure via webhook). On SMS, `error.code` is the\n    carrier's numeric code and `error.message` its reason.\n","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"},"details":{"type":"string","description":"Platform's extended detail for `code` (WhatsApp: Meta's `error_data.details`), when the platform sent one. Absent on SMS."},"href":{"type":"string","format":"uri","description":"Link to the platform's documentation for `code`, when the platform sent one."},"explanation":{"type":["string","null"],"description":"Plain-language translation of `code` (e.g. for 131026, that the\nrecipient has likely opted out of marketing messages while utility\ntemplates are unaffected, or for 131031, that Meta restricted the\nWhatsApp Business Account). Null for unmapped codes; fall back to\ntitle/message.\n"}}},"conversation":{"$ref":"#/components/schemas/InboxWebhookConversation"},"account":{"$ref":"#/components/schemas/InboxWebhookAccount"},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"WebhookPayloadConversationStarted":{"type":"object","description":"Fired once when a new conversation begins, in either direction. A conversation\nstarts the first time an account and a contact exchange a message on any DM\nplatform (Instagram, Messenger/Facebook, Telegram, WhatsApp, X, Reddit,\nBluesky, SMS). Platform-agnostic: one subscription covers every DM platform.\n","required":["id","event","conversation","account","startedAt","timestamp"],"properties":{"id":{"type":"string","description":"Stable webhook event ID"},"event":{"type":"string","enum":["conversation.started"]},"conversation":{"$ref":"#/components/schemas/InboxWebhookConversationDetail"},"account":{"$ref":"#/components/schemas/InboxWebhookAccount"},"startedAt":{"type":"string","format":"date-time","description":"When the conversation document was created."},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"WebhookPayloadConversationControlChanged":{"type":"object","description":"WhatsApp only. Who answers a conversation changed: Meta Business Agent took it over,\nhanded it to you, or another partner app took it.\n","required":["id","event","conversation","account","control","changedAt","timestamp"],"properties":{"id":{"type":"string","description":"Stable webhook event ID"},"event":{"type":"string","enum":["conversation.control_changed"]},"conversation":{"$ref":"#/components/schemas/InboxWebhookConversationDetail"},"account":{"$ref":"#/components/schemas/InboxWebhookAccount"},"control":{"type":"object","required":["owner","previousOwner"],"properties":{"owner":{"type":"string","enum":["app","ai_agent","other"],"description":"Who answers now. ai_agent: Meta Business Agent; app: you; other: another partner app on the number."},"previousOwner":{"type":["string","null"],"enum":["app","ai_agent","other",null],"description":"Owner before this change, null when the thread had never been agent-handled."},"metadata":{"type":"string","description":"Free-form string the transferring app attached to the handover, forwarded verbatim."}}},"changedAt":{"type":"string","format":"date-time"},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"CallRecord":{"type":"object","description":"One call on a number you own, either channel. `channel` tells you which\nlane it took: `whatsapp` (WhatsApp Business Calling) or `pstn` (a regular\nphone call). List endpoints omit `transcript`; use `lastTranscriptSnippet`\nfor a preview and the detail endpoint for the full transcript.\n","properties":{"_id":{"type":"string"},"accountId":{"type":"string","description":"Owning 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\ninbound (UIC) and outbound (BIC) calls; branch on\n`call.direction` to tell them apart.\n","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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"WebhookPayloadCallEnded":{"type":"object","description":"Webhook payload for the `call.ended` event. Fires on call hangup\nwith the duration and a zero-markup billing breakdown.\n","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"]},"hangupCause":{"type":["string","null"],"description":"Raw carrier hangup cause behind endReason (e.g. normal_clearing, call_rejected, not_found). Null when the carrier reported none."},"sipHangupCause":{"type":["string","null"],"description":"SIP response code that ended the call when SIP-signalled (e.g. '403', '486', '603'). endReason collapses all three to 'rejected', so this is what separates a refused destination from a busy line. Null on non-SIP legs."},"isVoicemail":{"type":"boolean","description":"True when the inbound call was handled by voicemail, whether scheduled or because the forward did not connect."},"callErrors":{"type":"array","description":"Failures recorded on the call up to hangup (bridge failed, dial failed, recording error). Empty on a clean call. `message` is free-form diagnostic text and is not stable, do not parse it. `code` is 0 unless a provider code is known. Errors the carrier reports after hangup appear only on GET /v1/calls/{id}.","items":{"type":"object","properties":{"code":{"type":"integer"},"message":{"type":"string"}}}},"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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"WebhookPayloadCallFailed":{"type":"object","description":"Webhook payload for the `call.failed` event. Fired when a call\nsetup or in-progress call fails.\n","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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"WebhookPayloadCallPermissionRequest":{"type":"object","description":"Webhook payload for the `call.permission_request` event. Fires\nwhen a consumer accepts or rejects an interactive\n`call_permission_request` message.\n","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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"WebhookPayloadAdStatusChanged":{"type":"object","description":"Webhook payload for the `ad.status_changed` event. Currently emitted\nonly for Meta (`metaads`).\n\nSourced from two Meta `ad_account` webhook fields:\n  - `in_process_ad_objects` - the ad object finished processing and\n    exited `IN_PROCESS`. `status.raw` carries Meta's `status_name`.\n  - `with_issues_ad_objects` - the ad object entered `WITH_ISSUES`.\n    `status.raw` is `WITH_ISSUES` and the `error` block is populated\n    from Meta's `error_code` / `error_summary` / `error_message`.\n","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\nthe bare numeric ID (e.g. `120244894077860689`).\n","example":"120244894077860689"},"platformAdAccountId":{"type":"string","description":"Platform-native ad-account ID. For Meta this uses the `act_<id>`\nshape.\n","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\nthis is `status_name` from `in_process_ad_objects` (e.g.\n`ACTIVE`, `PAUSED`, `PENDING_REVIEW`, `ARCHIVED`, `DELETED`,\n`DISAPPROVED`), or `WITH_ISSUES` when sourced from\n`with_issues_ad_objects`. Not constrained by an `enum`, because Meta\nmay add new values.\n","example":"ACTIVE"}}},"error":{"type":"object","description":"Optional. Present on most `WITH_ISSUES` events, carrying the\nplatform's error diagnostics. May be absent on some `WITH_ISSUES`\nevents (Meta does not always include diagnostics). Always absent\nfor any other `status.raw` value. Always null-check before reading.\n","required":["code"],"properties":{"code":{"type":"string","description":"Platform-native error code, forwarded verbatim. For Meta this\nis `error_code` as a string. Use as the stable discriminator, since\n`summary` and `message` are localized.\n","example":"2643001"},"summary":{"type":"string","description":"Short human-readable summary (Meta `error_summary`). Localized\nto the ad-account owner's Meta locale. Display only, do not\nmatch on it.\n","example":"Ad Processing Error"},"message":{"type":"string","description":"Full human-readable error message (Meta `error_message`).\nLocalized, display only.\n"}}},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"WebhookPayloadWhatsAppTemplateStatusUpdated":{"type":"object","description":"Webhook payload for the `whatsapp.template.status_updated` event.\nFired when Meta completes (re)review of a template attached to a\nconnected WABA. Maps Meta's `message_template_status_update` field\nonto our event envelope.\n","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.\n`PENDING_DELETION` is the 24h-grace state after a delete\nrequest before the template is actually removed.\n"},"reason":{"type":"string","description":"Meta's free-form reason for the transition. `\"NONE\"` on\napproval; an explanation string on rejection.\n"}}},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"WebhookPayloadWhatsAppTemplateCategoryUpdated":{"type":"object","description":"Webhook payload for the `whatsapp.template.category_updated` event.\nFired when Meta reclassifies a template's category attached to a\nconnected WABA. Maps Meta's `template_category_update` field onto\nour event envelope.\n","required":["id","event","account","template","timestamp"],"properties":{"id":{"type":"string","description":"Stable webhook event ID"},"event":{"type":"string","enum":["whatsapp.template.category_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","changeType","category"],"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`)."},"changeType":{"type":"string","enum":["scheduled","applied"],"description":"`scheduled` is Meta's 24h advance notice of an upcoming\nreclassification; `applied` is the change taking effect.\n"},"category":{"type":"string","enum":["UTILITY","MARKETING","AUTHENTICATION"],"description":"The category right now, regardless of changeType."},"previousCategory":{"type":"string","enum":["UTILITY","MARKETING","AUTHENTICATION"],"description":"Present only when changeType is `applied`. The category before this change."},"scheduledCategory":{"type":"string","enum":["UTILITY","MARKETING","AUTHENTICATION"],"description":"Present only when changeType is `scheduled`. The category that will take effect at `effectiveAt`."},"effectiveAt":{"type":"string","format":"date-time","description":"Present only when changeType is `scheduled`. ISO-8601 timestamp when the scheduled category takes effect."}}},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"WebhookPayloadWhatsAppAccountNameStatusUpdated":{"type":"object","description":"Webhook payload for the `whatsapp.account.name_status_updated` event.\nFired when Meta finishes reviewing a WhatsApp display-name change on a\nconnected number. Maps Meta's `phone_number_name_update` WABA webhook\nfield onto our event envelope. Fires only for a review outcome\n(APPROVED, DECLINED, PENDING_REVIEW); a name applied without review\nreports `name_status: AVAILABLE_WITHOUT_REVIEW` on the phone node\ninstead, and Meta never sends this webhook field for that case.\n","required":["id","event","account","name","timestamp"],"properties":{"id":{"type":"string","description":"Stable webhook event ID"},"event":{"type":"string","enum":["whatsapp.account.name_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"}}},"name":{"type":"object","required":["status","requestedName","rejectionReason","displayPhoneNumber"],"properties":{"status":{"type":"string","enum":["APPROVED","DECLINED","PENDING_REVIEW"],"description":"Normalized from Meta's `decision` (REJECTED -> DECLINED, DEFERRED -> PENDING_REVIEW; the review is still open on DEFERRED, not a rejection)."},"requestedName":{"type":["string","null"],"description":"The display name Meta reviewed. Null if Meta did not send one."},"rejectionReason":{"type":["string","null"],"description":"Meta's free-form decline reason. Null on approval, or when Meta sends the literal string \"NONE\"."},"displayPhoneNumber":{"type":["string","null"],"description":"The phone number this review is for, as Meta reported it."}}},"timestamp":{"type":"string","format":"date-time","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."}}},"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","description":"UTC time at which Zernio generated this test event (set once when the payload is built). Test fires are sent synchronously as a single attempt; a later redelivery of this event keeps the original value."}}},"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 (media-level restriction), and LinkedIn (organization pages only, min 300 targeted followers). Ignored on unsupported platforms. Stories (Facebook, Instagram) do not support geo-restriction.\n","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\"].\n","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","description":"A media item with a null, missing or empty url is dropped from non-draft posts (drafts keep it as a pending-upload placeholder)."},"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 (max 1000 chars), LinkedIn, Bluesky, and Pinterest (max 500 chars). Ignored on platforms without alt-text support (TikTok, YouTube, Snapchat, Telegram, Reddit, Google Business Profile, 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 and limited to image or video. Kept as a separate schema so the generated SDK model does not collide with MediaItem.\n","required":["type","url"],"properties":{"type":{"type":"string","enum":["image","video"]},"url":{"type":["string","null"],"description":"'Direct URL to the media file. Null when the platform withholds it: check mediaStatus before downloading. Instagram omits the video file for Reels it flags as containing copyrighted material (its docs name audio as the usual cause), so type stays \"video\" while the file is permanently unreachable. For LinkedIn videos where the platform returns no file, url falls back to the cover image and the item carries mediaStatus: unavailable.'"},"thumbnail":{"type":"string","description":"Cover image. Still present when url is null."},"mediaStatus":{"type":"string","enum":["available","unavailable"],"description":"unavailable means the media file could not be retrieved (url is null or, for LinkedIn videos, a cover image standing in for the file). available or absent means the file is available at url (older synced items omit the field)."},"unavailableReason":{"type":"string","enum":["platform_withheld"],"description":"Why the file is missing. platform_withheld means the platform declined to return it and retrying will not help."}}},"MediaContentType":{"type":"string","description":"MIME type of the file to upload. Rejected with a 400 (INVALID_FIELD_VALUE on contentType) when it is not one of these values, so generic types such as application/octet-stream are not accepted here.","example":"video/mp4","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"]},"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":{"type":"object","additionalProperties":true,"description":"The platform-specific options stored on this target, echoed back as they were sent. Typed per platform on the way in (see the *PlatformData schemas on the request body); free-form on the way out, because a response is not guaranteed to match exactly one of those variants and generated clients that pick a variant by structure reject the entire response when it doesn't. Zernio's internal publishing state (snapshots, container ids, publish stage) is never returned here, and the key is omitted rather than sent as an empty object."},"status":{"type":"string","example":"pending","description":"Platform-specific status: pending, processing, uploading, published, failed, cancelled (removed from the platform via DELETE /v1/posts/{postId}/unpublish)"},"platformPostId":{"type":"string","description":"The native post ID on the platform (populated after successful publish)","example":"1234567890123456789"},"platformPostUrl":{"type":["string","null"],"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. Empty when the platform confirmed the publish without returning an id a permalink can be built from (TikTok returns a publish id for some uploads); the TikTok reconcile cron backfills it later.","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","platform_rate_limit","quota_exhausted","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), platform_rate_limit (platform throttling, retried automatically), quota_exhausted (a shared quota pool the integration draws on is empty, including our own capacity gate in front of one; not caused by your content or account, and safe to retry once the pool frees up), 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":"Stored on the post for reference/display only. This field is NOT used as the video title when publishing. To set a YouTube video title, use platformSpecificData.title on the youtube platform target (falls back to the first line of content when omitted)."},"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","partial","failed","cancelled"],"description":"`cancelled` is set by DELETE /v1/posts/{postId}/unpublish once every platform entry has been removed from its platform (a post with published entries left becomes `partial`); cancelled posts can be edited and rescheduled like drafts."},"tags":{"type":"array","description":"YouTube constraints: each tag max 100 chars, combined max 500 chars, duplicates removed.","items":{"type":"string"}},"hashtags":{"type":"array","description":"Stored for reference only. Hashtags are NOT automatically appended to the caption when publishing. Include hashtags directly in the content field (platforms like Instagram only support hashtags as caption text). For YouTube keywords, use the tags field instead.","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).\nAfter the post is published, the system creates new scheduled copies at the\nspecified interval until expiration conditions are met. Supports weekly or\nmonthly intervals. Maximum 10 active recycling posts per account.\nYouTube and TikTok platforms are excluded from recycling.\nContent variations are recommended for X and Pinterest to avoid duplicate flags.\n","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\nvariation is used in round-robin order. Recommended for X and Pinterest\nto avoid duplicate content flags. If omitted, the original post content is\nused for all recycled copies. Send an empty array [] to clear existing\nvariations. Must have 2+ entries when setting variations. Platform-level\ncustomContent still overrides the base content per platform.\n"}}},"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)"}}},"XArticleTextRange":{"type":"object","additionalProperties":false,"required":["from_index","to_index","text"],"properties":{"from_index":{"type":"integer","minimum":0,"description":"Inclusive start index in the block text."},"to_index":{"type":"integer","minimum":0,"description":"Exclusive end index in the block text. Must be greater than or equal to from_index and no greater than the block text length."},"text":{"type":"string","description":"Text represented by this metadata range."}}},"XArticleInlineStyleRange":{"type":"object","additionalProperties":false,"required":["offset","length","style"],"properties":{"offset":{"type":"integer","minimum":0},"length":{"type":"integer","minimum":0},"style":{"type":"string","enum":["bold","italic","strikethrough"]}},"description":"The offset plus length must not exceed the containing block's text length."},"XArticleEntityRange":{"type":"object","additionalProperties":false,"required":["key","offset","length"],"properties":{"key":{"type":"integer","minimum":0,"description":"Zero-based index into content_state.entities."},"offset":{"type":"integer","minimum":0},"length":{"type":"integer","minimum":0}},"description":"The referenced entity must exist, and offset plus length must not exceed the containing block's text length."},"XArticleBlock":{"type":"object","additionalProperties":false,"required":["type","text"],"properties":{"type":{"type":"string","enum":["unstyled","header-one","header-two","header-three","unordered-list-item","ordered-list-item","blockquote","atomic"]},"text":{"type":"string"},"key":{"type":"string","minLength":1},"data":{"type":"object","additionalProperties":false,"properties":{"cashtags":{"type":"array","items":{"$ref":"#/components/schemas/XArticleTextRange"}},"hashtags":{"type":"array","items":{"$ref":"#/components/schemas/XArticleTextRange"}},"mentions":{"type":"array","items":{"$ref":"#/components/schemas/XArticleTextRange"}},"urls":{"type":"array","items":{"$ref":"#/components/schemas/XArticleTextRange"}}}},"inline_style_ranges":{"type":"array","items":{"$ref":"#/components/schemas/XArticleInlineStyleRange"}},"entity_ranges":{"type":"array","items":{"$ref":"#/components/schemas/XArticleEntityRange"}}}},"XArticleEntity":{"oneOf":[{"type":"object","additionalProperties":false,"required":["key","value"],"properties":{"key":{"type":"string","minLength":1},"value":{"type":"object","additionalProperties":false,"required":["type","mutability","data"],"properties":{"type":{"type":"string","const":"post"},"mutability":{"type":"string","enum":["immutable","mutable","segmented"]},"data":{"type":"object","additionalProperties":false,"required":["post_id"],"properties":{"post_id":{"type":"string","minLength":1}}}}}}},{"type":"object","additionalProperties":false,"required":["key","value"],"properties":{"key":{"type":"string","minLength":1},"value":{"type":"object","additionalProperties":false,"required":["type","mutability","data"],"properties":{"type":{"type":"string","const":"link"},"mutability":{"type":"string","enum":["immutable","mutable","segmented"]},"data":{"type":"object","additionalProperties":false,"required":["url"],"properties":{"url":{"type":"string","format":"uri"}}}}}}},{"type":"object","additionalProperties":false,"required":["key","value"],"properties":{"key":{"type":"string","minLength":1},"value":{"type":"object","additionalProperties":false,"required":["type","mutability","data"],"properties":{"type":{"type":"string","const":"image"},"mutability":{"type":"string","enum":["immutable","mutable","segmented"]},"data":{"type":"object","additionalProperties":false,"required":["url"],"properties":{"url":{"type":"string","format":"uri","pattern":"^https?://.+\\.(?:[jJ][pP][eE]?[gG]|[pP][nN][gG]|[wW][eE][bB][pP]|[gG][iI][fF]|[mM][pP]4)(?:[?#].*)?$","description":"Public image, GIF, or MP4 URL. Zernio uploads it to X and replaces it with native media metadata. Caller-supplied media IDs are rejected."},"caption":{"type":"string"},"altText":{"type":"string","maxLength":1000,"description":"Supported for image and GIF URLs; rejected for MP4 media."}}}}}}},{"type":"object","additionalProperties":false,"required":["key","value"],"properties":{"key":{"type":"string","minLength":1},"value":{"type":"object","additionalProperties":false,"required":["type","mutability","data"],"properties":{"type":{"type":"string","const":"emoji"},"mutability":{"type":"string","enum":["immutable","mutable","segmented"]},"data":{"type":"object","additionalProperties":false,"required":["entity_key"],"properties":{"entity_key":{"type":"string","minLength":1}}}}}}},{"type":"object","additionalProperties":false,"required":["key","value"],"properties":{"key":{"type":"string","minLength":1},"value":{"type":"object","additionalProperties":false,"required":["type","mutability","data"],"properties":{"type":{"type":"string","const":"markdown"},"mutability":{"type":"string","enum":["immutable","mutable","segmented"]},"data":{"type":"object","additionalProperties":false,"required":["markdown"],"properties":{"markdown":{"type":"string"}}}}}}},{"type":"object","additionalProperties":false,"required":["key","value"],"properties":{"key":{"type":"string","minLength":1},"value":{"type":"object","additionalProperties":false,"required":["type","mutability","data"],"properties":{"type":{"type":"string","enum":["divider","latex"]},"mutability":{"type":"string","enum":["immutable","mutable","segmented"]},"data":{"type":"object","additionalProperties":false}}}}}],"description":"Entity keys must be unique decimal strings matching their zero-based array index (`\"0\"`, `\"1\"`, ...)."},"XArticleContentState":{"type":"object","additionalProperties":false,"externalDocs":{"description":"Official X Article content format reference","url":"https://docs.x.com/x-api/articles/create-draft-article"},"required":["blocks","entities"],"properties":{"blocks":{"type":"array","minItems":1,"items":{"$ref":"#/components/schemas/XArticleBlock"}},"entities":{"type":"array","items":{"$ref":"#/components/schemas/XArticleEntity"}}},"description":"X's snake_case content-state shape. Standard DraftJS camelCase fields such as entityMap, inlineStyleRanges, and entityRanges are rejected."},"XArticle":{"type":"object","additionalProperties":false,"required":["title","content_state"],"properties":{"title":{"type":"string","minLength":1},"content_state":{"$ref":"#/components/schemas/XArticleContentState"},"mode":{"type":"string","enum":["publish","draft"],"default":"publish","description":"Publish creates an X Article draft and then publishes it. Draft stops after draft creation and returns the X draft ID without a public URL."},"cover":{"type":"object","additionalProperties":false,"required":["url"],"properties":{"url":{"type":"string","format":"uri","pattern":"^https?://.+\\.(?:[jJ][pP][eE]?[gG]|[pP][nN][gG]|[wW][eE][bB][pP])(?:[?#].*)?$","description":"Public JPG, JPEG, PNG, or WebP URL. GIF, MP4, AVIF, extensionless URLs, and caller-supplied native media IDs are rejected."},"altText":{"type":"string","maxLength":1000}}}},"description":"Long-form X Article payload. X Articles require an eligible X Premium+ account.\nArticles are mutually exclusive with top-level/custom tweet media and with threadItems,\npoll, quoteTweetId, replyToTweetId, inReplyToTweetId, replySettings, sensitiveMedia,\npaidPartnership, and madeWithAi. Publishing normally performs two billable X API\nrequests at $0.010 each (draft + publish, $0.020 total); mode `draft` performs only\nthe $0.010 draft request. `articleDraftId` is an internal recovery checkpoint and\nmust not be supplied by API clients.\n"},"TwitterPlatformData":{"type":"object","properties":{"article":{"$ref":"#/components/schemas/XArticle"},"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].\n","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":"Uploads the video with X's amplify_video media category instead of the standard tweet_video. Applied only when the connected X account has a paid X subscription; on other accounts the flag is accepted and ignored. It is not required to post long videos. The standard tweet_video path already publishes videos well past 140 seconds on free accounts, and maximum duration is set by X per account, not by Zernio. Zernio enforces only the 512 MB file-size limit. Some accounts additionally require X's long-video API allowlisting, without which X rejects an amplify_video upload."},"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-specific post options. The article field creates a long-form X Article and is mutually exclusive with tweet media and tweet-only options. Geo-restriction applies at the media level: media is hidden outside the specified countries while tweet text remains visible.\n"},"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."},"firstComment":{"type":"string","maxLength":500,"description":"Optional first comment to post immediately after publishing, as a reply to the published post. With threadItems, it replies to the root post. Up to 500 characters (the Threads post limit). The reply is itself a Threads post, so it consumes one of the 250 posts a profile may publish per 24 hours."},"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].\n","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 a single vertical video (9:16). The Zernio API does not preflight Reel duration. Meta's Reels publishing guide documents 3-90 seconds. 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.\n"},"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.\n","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":10,"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.\n","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.\n"},"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.\n"}}},"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. Unrelated to audioConfiguration, which attaches a catalog track.","example":"My Podcast Intro"},"audioConfiguration":{"type":"object","description":"Attach a licensed music track or original sound from the Instagram audio catalog to a Reel. Reels only (single video post, not a story or image). Requires an Instagram account connected via Facebook Login; classic Instagram Login accounts get a 400 (instagram_audio_requires_facebook_login). Get audio IDs from GET /v1/accounts/{accountId}/instagram/audio. If the track becomes unavailable by publish time (removed, region-blocked, licensing change), the post fails with a user-error; it is not published without the audio.","required":["audioId"],"properties":{"audioId":{"type":"string","description":"Audio asset ID from the audio search endpoint.","example":"482851939985510"},"audioVolume":{"type":"integer","minimum":0,"maximum":100,"description":"Volume of the attached audio track, 0-100. Defaults to 100.","example":80},"videoVolume":{"type":"integer","minimum":0,"maximum":100,"description":"Volume of the video's own sound, 0-100. Defaults to 100. Set 0 to mute the original video audio.","example":100}}},"muteAudio":{"type":"boolean","default":false,"description":"Publish the video without sound. Applies to Reels, Stories, and video carousel slides; ignored for images. Instagram has no mute parameter, so we strip the audio track from the file before handing it to Instagram: the published video is permanently silent and the original audio cannot be restored from Instagram. If the audio cannot be stripped the post fails rather than publishing with sound; videos above 200MB cannot be muted at all, so mute them before uploading. Unrelated to audioConfiguration.videoVolume, which only lowers the original sound when a catalog track is attached.","example":true},"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."},"isPaidPartnership":{"type":"boolean","default":false,"description":"When true, Instagram shows the \"Paid partnership\" label on the post. Applies to feed posts, Reels, and carousels; not supported on Stories (400). Requires an Instagram account connected via Facebook Login; classic Instagram Login accounts get a 400 (instagram_paid_partnership_requires_facebook_login). Implied when brandedContentSponsors is set.","example":true},"brandedContentSponsors":{"type":"array","maxItems":2,"items":{"type":"string"},"description":"Up to 2 brands to tag as sponsors, each an Instagram username (leading @ optional) or a numeric Instagram user ID. Usernames are resolved at publish time via the Business Discovery API on the publishing account; a sponsor that cannot be resolved (private, personal, or nonexistent account) fails the post with a user error naming it. Sponsors must be professional (Business or Creator) accounts. A brand that has pre-approved the creator shows as \"Paid partnership with @brand\" immediately; otherwise the plain label shows and the brand receives an approval request. Sets isPaidPartnership. Same login and content-type rules as isPaidPartnership.","example":["nike","17841400000000000"]},"commentsEnabled":{"type":"boolean","default":true,"description":"When false, comments are turned off on the post right after it is published (Meta exposes this as comment_enabled on the media object). Applies to feed posts, Reels, and carousels; ignored for Stories, which have no comments. Works with both Instagram connection methods. Best-effort: if the toggle fails after a successful publish, the post still succeeds and stays live with comments on.","example":false},"locationId":{"type":"string","pattern":"^[0-9]+$","description":"Tags the post with a location. The ID of a Facebook Page that has location data (digits only); it is sent to Instagram as location_id. Applies to feed posts, Reels, and the carousel as a whole; Stories and individual carousel slides are unsupported (a Story with locationId is rejected with a 400). A Page without location data or that does not exist fails the post with a user error at publish time.","example":"105890561436614"}},"description":"Feed aspect ratio 0.5625-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."},"InstagramAudioAsset":{"type":"object","description":"One asset from the Instagram audio catalog. Licensed music carries artist/artwork fields; original sounds carry creator fields instead, so most fields are nullable.","properties":{"audioId":{"type":"string","description":"Audio asset ID. Pass it as platformSpecificData.audioConfiguration.audioId when creating a Reel.","example":"482851939985510"},"title":{"type":["string","null"],"description":"Track or sound title."},"audioType":{"type":["string","null"],"enum":["music","original_sound",null],"description":"Catalog type of the asset."},"durationInMs":{"type":["integer","null"],"description":"Asset duration in milliseconds."},"displayArtist":{"type":["string","null"],"description":"Artist name (licensed music only)."},"coverArtworkThumbnailUrl":{"type":["string","null"],"description":"Cover artwork thumbnail (licensed music only)."},"downloadUrl":{"type":["string","null"],"description":"Temporary preview URL. Meta expires it after roughly 1.5 days; re-fetch the asset to refresh it."},"igUsername":{"type":["string","null"],"description":"Creator username (original sounds only)."},"profilePictureUrl":{"type":["string","null"],"description":"Creator profile picture (original sounds only)."},"isAdsEligible":{"type":["boolean","null"],"description":"Whether the asset is eligible for ads use."},"onPlatformAudioPreviewLink":{"type":["string","null"],"description":"Instagram web link to preview the audio."}}},"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 is always a reshare authored by your account with the original embedded underneath: with content your text is the commentary (LinkedIn's \"repost with your thoughts\"), and with no content it publishes as a text-free reshare. A text-free reshare is NOT LinkedIn's one-click \"Repost\" (the feed treatment where the original author stays the author); LinkedIn's API exposes no way to create that, so the post still appears authored by you with the original embedded. Mutually exclusive with media. Works on personal profiles and organization pages."},"geoRestriction":{"$ref":"#/components/schemas/GeoRestriction"},"poll":{"type":"object","description":"Create a LinkedIn poll with this post. Cannot be combined with media or reshareUrl. Polls cannot be edited after publishing on LinkedIn, and API-created polls are non-sponsored only (they cannot be promoted as ads).","properties":{"question":{"type":"string","minLength":1,"maxLength":140,"description":"Poll question (max 140 characters)"},"options":{"type":"array","minItems":2,"maxItems":4,"items":{"type":"string","minLength":1,"maxLength":30},"description":"Poll options (2-4 choices, max 30 characters each)"},"duration":{"type":"string","enum":["ONE_DAY","THREE_DAYS","SEVEN_DAYS","FOURTEEN_DAYS"],"default":"SEVEN_DAYS","description":"How long the poll accepts votes. Defaults to SEVEN_DAYS."}},"required":["question","options"]}},"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. Polls are supported via the poll object: 2-4 options, cannot be combined with media or reshareUrl, cannot be edited after publishing, and API-created polls are non-sponsored only.\n"},"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\"."}}},"MetaAdsPlatformData":{"type":"object","additionalProperties":false,"description":"Meta (facebook/instagram) options for platformSpecificData on POST /v1/ads/boost and /v1/ads/create. Unknown keys are rejected, not dropped.","properties":{"bidStrategy":{"$ref":"#/components/schemas/BidStrategy"},"bidAmount":{"type":"number","description":"Whole currency units (USD: 5 = $5.00). Required when bidStrategy is LOWEST_COST_WITH_BID_CAP or COST_CAP. May also be sent alone, WITHOUT bidStrategy, to set the cap on an ad set joining a COST_CAP / LOWEST_COST_WITH_BID_CAP campaign (the strategy is inherited from the campaign). On POST /v1/ads/create that shape requires existingCampaignId and is a 400 otherwise; on POST /v1/ads/boost it is promoted to LOWEST_COST_WITH_BID_CAP."},"roasAverageFloor":{"type":"number","description":"Decimal ROAS multiplier (2.0 = 2.0x). Required when bidStrategy is LOWEST_COST_WITH_MIN_ROAS; sending it without bidStrategy is a 400."},"dailyMinSpendTarget":{"type":"number","description":"Meta daily_min_spend_target on the ad set being created: the least it should spend per day, in whole currency units. It reserves a share of a CAMPAIGN budget, so it requires budgetLevel campaign or an existingCampaignId whose campaign has the budget (Advantage campaign budget / CBO); with an ad-set budget it is a 400, because Meta rejects a spend limit on an ad set that owns its budget. A target, not a guarantee. Mutually exclusive with lifetimeMinSpendTarget: the flavour must match the campaign budget type. Rejected with 400 on POST /v1/ads/boost and in adSetId attach mode: use PUT /v1/ads/ad-sets/{adSetId} for an ad set that already exists."},"lifetimeMinSpendTarget":{"type":"number","description":"Meta lifetime_min_spend_target: the lifetime-budget flavour of dailyMinSpendTarget, in whole currency units. Same rules and same rejections."}}},"LinkedInAdsPlatformData":{"type":"object","additionalProperties":false,"properties":{"costType":{"type":"string","enum":["CPM","CPC","CPV"],"description":"Campaign cost model (billing event). Defaults to `CPM`. Required when\n`unitCost` is set so the manual bid applies to an explicit cost model.\n"},"unitCost":{"type":"number","description":"Manual bid in WHOLE account-currency units (e.g. 2.5 = $2.50). Requires\n`costType`. Omit for LinkedIn's automated (max delivery) bidding.\nLinkedIn enforces its own per-audience min/max bid bounds.\n"},"optimizationTargetType":{"type":"string","description":"Campaign `optimizationTargetType` (e.g. `MAX_CLICK`, `TARGET_COST_PER_CLICK`,\n`MAX_IMPRESSION`). Forwarded verbatim, LinkedIn validates compatibility with\nthe objective and `costType`. Omit for the objective-derived default:\n`awareness` gets `MAX_IMPRESSION`, `video_views` gets `MAX_VIDEO_VIEW`, and\nevery other goal gets `MAX_CLICK`. `lead_generation` and `conversions` also\nget `MAX_CLICK`, because `MAX_LEAD` and `MAX_CONVERSION` need a lead gen form\nor a conversion rule that neither creation flow attaches. The default applies\nonly to `SPONSORED_UPDATES` campaigns (every boost, and the image, video and\ncarousel standalone ads), never to the `TEXT_AD`, `DYNAMIC` and\n`SPONSORED_INMAILS` campaigns the other creative formats produce. It is also\nskipped when `unitCost` or a non-`CPM` `costType` is set, since those select\nmanual bidding and the bid is then yours to choose.\n"},"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.\nMutually exclusive with the other creative sources.\n","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\ncard; the ad-level `linkUrl` backfills cards that omit it.\n"}}}}}},"document":{"type":"object","additionalProperties":false,"required":["url","title"],"description":"POST /v1/ads/create only. Document ad rendered as an in-feed viewer.\nPDF, PPT or DOC up to 100MB. Mutually exclusive with the other\ncreative sources.\n","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\nviewer's profile photo. Supported goals: traffic, awareness. logoUrl\nand organizationName default to the Company Page's; set them\nexplicitly if LinkedIn rejects the create with a 404. Mutually\nexclusive with the other creative sources.\n","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\nPage. Supported goals: engagement, awareness. headline and\ndescription take exactly one of preApproved or custom. Mutually\nexclusive with the other creative sources.\n","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,\npersonalized with the viewer's profile photo. Requires goal\njob_applicants and a Company Page with active job postings.\nheadline and buttonLabel take exactly one of\npreApproved or custom. logoUrl and organizationName default to the\nCompany Page's. Mutually exclusive with the other creative sources.\n","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\nhere; ad-level body and headline are not used. Mutually exclusive\nwith the other creative sources.\n","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\ntree delivered to the member's LinkedIn inbox. Messages are flat\nnodes wired by local ids; each button either opens a url or leads to\nnextMessageId. Cycles, unknown ids and a missing firstMessageId\nreturn a 400. LinkedIn does not deliver message ads to EU members.\nMutually exclusive with the other creative sources.\n","properties":{"subject":{"type":"string","description":"InMail subject shown in the inbox."},"sender":{"type":"string","description":"Person or organization URN. Defaults to the authoring Company\nPage. The sender must be approved for the ad account first\n(Campaign Manager > Manage message ad senders) or LinkedIn\nrejects the create with SINMAIL_SENDER_NOT_APPROVED.\n"},"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\nheadline needed. Mutually exclusive with the other creative sources.\n","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\n(a share or ugcPost authored by your organization's Company\nPage) as the creative, keeping its commentary, author and\nengagement. Unlike boostPost, which provisions its own\nCampaignGroup + Campaign around the post, this variant\nattaches the reference under the campaign /v1/ads/create\nbuilds, the same shape as every other format, so the caller can\npick bidding / targeting / schedule freely. No headline, body,\nimageUrl or organization are needed; the referenced post\ncarries its own commentary and author. Mutually exclusive\nwith the other creative sources. Posts from personal profiles\n(Thought Leader Ads) are NOT supported (see postUrn).\n","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.\n"}}}},"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.\n"},"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."},"boardSectionId":{"type":"string","description":"Target section inside the board. Optional; the pin lands on the board itself when omitted. Pinterest rejects the pin if the section does not belong to boardId, so send both together."},"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"},"isAiGenerated":{"type":"boolean","default":false,"description":"When true, the Pin is created with Pinterest's AI_MODIFIED disclosure (ai_disclosures), which shows an \"AI modified\" label. Applies to image and video Pins. Pinterest offers no \"not AI\" value, so false omits the disclosure. Pinterest may still label a Pin on its own detection."}}},"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. There is no description field here: the video description comes from the post content, or from customContent on this platform target when set, and is truncated to 5000 characters."},"GoogleBusinessPlatformData":{"type":"object","properties":{"locationId":{"type":"string","description":"Target Google Business Profile 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 Google Business Profile, 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.\nprivacyLevel must match creator_info options. Both camelCase and snake_case accepted.\n\nCreator Inbox (draft mode): Set draft: true to send content to the TikTok Creator Inbox\ninstead of publishing immediately. The creator receives an inbox notification and completes\nthe post using TikTok's editing flow. This maps to TikTok's post_mode: \"MEDIA_UPLOAD\" internally.\n\nImportant: The field publish_type is NOT supported. Use draft: true for Creator Inbox flow.\n\nPhoto drafts use the /v2/post/publish/content/init/ endpoint with post_mode: \"MEDIA_UPLOAD\".\nVideo drafts use the dedicated /v2/post/publish/inbox/video/init/ endpoint.\n\nWhen draft: true, the video.upload scope is required. When draft is false or omitted\n(direct post), the video.publish scope is required. For Creator Inbox, TikTok app version\nmust be 31.8 or higher.\n","properties":{"draft":{"type":"boolean","description":"When true, sends the post to the TikTok Creator Inbox as a draft instead of publishing\nimmediately. The creator receives an inbox notification to complete posting via TikTok's\nediting flow. Maps to TikTok API post_mode: \"MEDIA_UPLOAD\" (photos) or the dedicated\ninbox endpoint (videos). When false or omitted, publishes directly via post_mode: \"DIRECT_POST\".\nNote: publish_type is not a supported field. Use this field instead.\n"},"privacyLevel":{"type":"string","description":"One of the values returned by the TikTok creator info API for the account. Accounts connected through the TikTok for Business app publish videos as public only: a non-public value on a video post is rejected at creation unless draft is true (photo posts keep every level)."},"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\"\n(\"Your Brand\") implies isBrandOrganicPost and \"brand_content\" (\"Branded Content\",\npaid partnership) implies brandPartnerPromote, so you don't need to send the\nboolean flags separately. Branded content cannot be posted with privacyLevel\nSELF_ONLY.\n"},"brandPartnerPromote":{"type":"boolean","description":"Whether the post promotes a brand partner (branded content / paid partnership).\nOnly needed to disclose BOTH types at once (set it alongside\ncommercialContentType \"brand_organic\"), or to override the value implied by\ncommercialContentType.\n"},"isBrandOrganicPost":{"type":"boolean","description":"Whether the post promotes the creator's own brand (brand organic). Only needed\nto disclose BOTH types at once (set it alongside commercialContentType\n\"brand_content\"), or to override the value implied by commercialContentType.\n"},"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). Any downloadable URL works: we rehost it ourselves. The image is stitched as a single frame at the start of the video to serve as the cover. Accounts connected through the TikTok for Business app hand it to TikTok as the cover instead, with no stitching, falling back to videoCoverTimestampMs without it. 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). With the brand-organic or branded-content toggle on, TikTok allows Commercial Music Library tracks only, so this attaches nothing there; use musicSoundInfo instead."},"musicSoundInfo":{"type":"object","description":"Commercial Music Library track to attach. Accounts connected through the TikTok for Business app only: a developer-app account rejects the post at publish time with a message that says so. Pick musicSoundId from GET /v1/accounts/{accountId}/tiktok/commercial-music. Ignored on drafts, where TikTok ignores every post_info field.","required":["musicSoundId"],"properties":{"musicSoundId":{"type":"string","description":"The id field of a track from GET /v1/accounts/{accountId}/tiktok/commercial-music (a song clip id). TikTok fails the publish with a generic 51065 when given the commercial music id instead."},"musicSoundVolume":{"type":"integer","minimum":0,"maximum":100,"default":50,"description":"Track volume. TikTok defaults an omitted volume to 0, which publishes the track silently, so we default to the app's 50. Video posts only."},"musicSoundStart":{"type":"integer","minimum":0,"description":"Start point of the track in milliseconds (default 0). Video posts only."},"musicSoundEnd":{"type":"integer","minimum":0,"description":"End point of the track in milliseconds (default: the video length). Must be greater than musicSoundStart. Video posts only."}}},"videoOriginalSoundVolume":{"type":"integer","minimum":0,"maximum":100,"description":"Volume of the video's own sound when a commercial track is attached (0 to 100). Requires musicSoundInfo. Video posts only."},"videoMadeWithAi":{"type":"boolean","description":"Set true to disclose AI-generated content. Accounts connected through the TikTok for Business app carry the disclosure on video posts only: the business photo endpoint has no AI disclosure field, so true on a direct photo post is rejected at creation rather than published undisclosed. Send draft true to publish such a photo post and set the disclosure in the TikTok app."},"locationId":{"type":"string","description":"Location tag to attach, as the id of a result from GET /v1/accounts/{accountId}/tiktok/locations. Accounts connected through the TikTok for Business app and video posts only: a developer-app account rejects the post at publish time with a message that says so, and a direct photo post is rejected at creation since the business photo endpoint has no location field. Requires locationName. Ignored on drafts, where TikTok ignores every post_info field."},"locationName":{"type":"string","description":"Display name of the location tag, as returned next to its id. Required with locationId; a locationId without it is rejected at creation."},"isAdsOnly":{"type":"boolean","description":"Set true to publish the video as an \"Only show in ads\" post: it is kept off the profile and exists to be used as a Spark Ad. Accounts connected through the TikTok for Business app and video posts only, with the same rejections as locationId. Ignored on drafts."},"description":{"type":"string","maxLength":4000,"description":"Optional long-form caption for photo posts (max 4000 chars). Recommended when content exceeds 90 chars, as photo titles are auto-truncated. Falls back to the post content when omitted."}}},"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-only self post and skips native media uploads, even when media is provided. For native video with body text, omit forceSelf and supply content plus a video mediaItem."},"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 explicitly publish an external link instead. The post content (or Reddit customContent override) is included as Markdown body text on the native video. When body text is present, upload failures or subreddit video restrictions fail the post without falling back to a link. Automatic link fallback applies only without body text.\n","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":{"langs":{"type":"array","minItems":1,"maxItems":3,"items":{"type":"string"},"example":["pt","en"],"description":"Language(s) of the post text as 1-3 BCP-47 codes (e.g. \"pt\", \"en-US\"), written to the post record's langs field. Bluesky feed generators filter on this field, so posts without it never appear in language-scoped feeds. Can only be set at creation (Bluesky has no post editing). When threadItems is used, every item in the thread carries the same langs. When omitted, the account's default (set via PATCH /v1/accounts/{accountId}/bluesky-settings) applies; with no default either, the field is absent from the record.\n"},"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].\n","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. Use langs to tag post language for feed-generator filtering.\n"},"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.\n"},"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).\n"},"DiscordScheduledEvent":{"type":"object","description":"Discord guild scheduled event. Returned by /v1/discord/guilds/{guildId}/events endpoints.\nFields below are the subset Zernio consumes. Discord may return more (e.g. creator,\nimage hash) which we pass through verbatim.\n","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","sms","phone","rcs"]},"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.\nWhile true, GET /v1/connect/{platform}/ads returns a\nfresh authUrl (implicit force=true) instead of alreadyConnected,\nso re-running the connect flow recovers the account. Cleared\nautomatically when the account is re-authorized.\n"},"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\nor derive from a posting account's OAuth token. null for standalone ads (Google Ads)\nand all posting accounts. Meta ads business-login accounts also have no parent.\n"},"enabled":{"type":"boolean","description":"Whether the user explicitly activated this account. false means the account was\ncreated as a side effect (e.g., posting account auto-created when user connected\nads first). Such accounts are hidden from this list, cannot be posted to\n(`ACCOUNT_NOT_ENABLED_FOR_POSTING`), and are not billed as connected accounts.\n"},"metadata":{"type":"object","description":"Platform-specific metadata. Fields vary by platform. For WhatsApp accounts, includes:\n- qualityRating: Phone number quality rating from Meta (GREEN, YELLOW, RED, or UNKNOWN)\n- 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).\n- 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.\n- verifiedName: Meta-verified business display name\n- displayPhoneNumber: Formatted phone number (e.g., \"+1 555-123-4567\")\n- wabaId: WhatsApp Business Account ID\n- phoneNumberId: Meta phone number ID\n\nFor Meta ads business-login accounts:\n- tokenType: system-user\n- businessId: The owning Business Manager ID when there is one owner; null for multiple owners.\n- businessIds: Owning Business Manager IDs discovered from granted ad accounts.\n- grantedAdAccountIds: Ad-account IDs granted to the token.\n- adAccountBusinesses: Map from ad-account ID to its owning business ID or null.\n- availablePages: Granted Page IDs and names. No Page tokens are exposed.\n- selectedPageId: The Page selected for creatives and lead forms, or null.\n- scopedAdAccountIds: Existing sync scope preserved on reconnect.\nNon-expiring tokens have no tokenExpiresAt field. Parent posting reconnects do not replace this token.\n\nFor LinkedIn accounts, profileData carries the profile details refreshed on each daily snapshot:\n- profileData.bio: The member's headline for personal accounts, or the organization description for organization accounts. null when the member has not set one.\n- 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.\n"}}},"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.\nFields vary by platform. Only present if metadata has been captured.\n","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)"},"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)"},"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 direct 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\nholding at least one live token.\n","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 /\naccess status. Returned by `GET /v1/billing`.\n","properties":{"billingSystem":{"type":"string","enum":["metronome","stripe","shopify"]},"plan":{"type":"object","properties":{"name":{"type":"string"},"isUsageBased":{"type":"boolean"},"isPaid":{"type":"boolean","description":"True when the key belongs to an account with an active paid billing relationship (Stripe subscription, usage-based billing, or Shopify-managed billing)."}}},"shopifyShopDomain":{"type":["string","null"],"description":"myshopify.com domain owning the subscription; present only when billingSystem is shopify."},"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 the usage-based invoice\nbreakdown (the CHARGE view). Returned by `GET /v1/usage`.\n","properties":{"supported":{"type":"boolean","description":"False for legacy Stripe accounts (no usage-based 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","null"],"description":"Peak counts over the window (usage-based COUNT metrics + live active-number count). Null when `profileId` / `accountId` is set.","properties":{"accounts":{"type":"integer"},"numbers":{"type":"integer"}}},"callUsage":{"type":["object","null"],"description":"Billable call volumes over the window. Null when `profileId` / `accountId` is set.","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\nStripe Tax against the billing address (the same engine the real\ninvoice uses; invoices apply exclusive tax, so the card is charged\ntotal + tax). Null when the account has no billing address on\nfile, the total is zero or negative, or the estimate failed.\n","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)."}}},"attribution":{"type":"object","description":"Present with `groupBy`. The window's spend split per profile or account; `sum(groups) + unattributed` equals `totals` per product.","properties":{"groupBy":{"type":"string","enum":["profile","account"]},"groups":{"type":"array","items":{"$ref":"#/components/schemas/UsageAttributionGroup"}},"unattributed":{"$ref":"#/components/schemas/UsageAttributionSlice","description":"Spend no profile/account can claim: credits, 10DLC fees, Verify, and usage whose record no longer resolves to an account. Zero for a restricted principal."},"totals":{"$ref":"#/components/schemas/UsageAttributionSlice","description":"The window totals; for a restricted principal, the sum of the visible groups."},"restricted":{"type":"boolean","description":"True when the caller (profile-scoped API key or member) cannot see every profile: `groups` are filtered, `totals` sum them, `unattributed` is zero, and the top-level `days` / `totals` / `lineItems` are projected onto the visible groups with `peaks`, `callUsage` and `tax` null."}}},"scope":{"description":"Present with `profileId` / `accountId`: echoes the group the payload was projected onto.","oneOf":[{"type":"object","required":["profileId"],"properties":{"profileId":{"type":"string"}}},{"type":"object","required":["accountId"],"properties":{"accountId":{"type":"string"}}}]}}},"UsageAttributionGroup":{"allOf":[{"$ref":"#/components/schemas/UsageAttributionSlice"},{"type":"object","required":["key"],"properties":{"key":{"type":"string","description":"Profile id or account id, per `groupBy`."}}}]},"UsageAttributionSlice":{"type":"object","properties":{"byProduct":{"type":"object","description":"USD per product family.","properties":{"accounts":{"type":"number"},"numbers":{"type":"number"},"calls":{"type":"number"},"sms":{"type":"number"},"verify":{"type":"number"},"dlc":{"type":"number"},"xApi":{"type":"number"},"credits":{"type":"number"},"other":{"type":"number"}}},"totalUsd":{"type":"number"}}},"UsageStats":{"type":"object","description":"Plan and usage stats. The response shape depends on `billingSystem`:\n  * Stripe users (default): per-period counters like `usage.uploads` and\n    `usage.profiles` are returned, scoped by the plan's `limits`.\n  * Usage-based billing users: `limits` are unlimited (-1). The\n    `usage` block carries connected-account and per-X-operation counts,\n    and the `spend` block carries current-period costs plus the X cap.\n","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 accounts on usage-based billing."},"limits":{"type":"object","description":"Plan limits. For accounts on usage-based billing 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`:\nStripe returns `uploads` / `profiles` / `lastReset`;\nusage-based billing returns `connectedAccounts` / `xApiCalls` / `xApiCallsByOperation`.\n","properties":{"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."},"connectedAccounts":{"type":"integer","description":"Usage-based billing only. Accounts currently connected across the team."},"xApiCalls":{"type":"object","deprecated":true,"description":"**Deprecated.** Legacy 3-tier aggregate. Operations outside the\nthree historical prices ($0.005/$0.010/$0.015), notably the\n$0.200 \"Posts with URL\" tier added April 2026, are silently\nexcluded from this shape. Use `xApiCallsByOperation` instead;\nit captures every tier and is the source of truth for\nper-operation call counts.\n","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":"Usage-based billing only. Per-operation X API call counts keyed by\noperation (e.g. `posts_read`, `content_create`,\n`content_create_with_url`). Resolve each key to price and metadata\nvia `GET /v1/billing/x-pricing`. This is the canonical source: it\ncovers every price tier including the $0.200 URL tier that\n`xApiCalls` excludes.\n","example":{"posts_read":42,"content_create":7,"content_create_with_url":3,"dm_interaction_create":1}}}},"spend":{"type":"object","description":"Usage-based billing 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 API spend in cents, summed from\n`xApiCallsByOperation` × per-operation prices. Tier-agnostic\n(covers every price including the $0.200 URL tier). Rounded\nup for conservative enforcement against `xSpendLimitCents`.\n"},"xSpendLimitCents":{"type":["integer","null"],"description":"Monthly X spend cap set by the account owner, or null if no cap.\nWhen current X spend hits this cap, analytics and inbox sync are\nauto-paused for X accounts. Publishing is never blocked by this cap.\n"}}}}},"XApiPricing":{"type":"object","description":"Canonical X API pricing table. Zernio passes X API costs through\nat exact rates with zero markup, so every call you make has a known per-unit\nprice. Use this payload alongside `/v1/usage-stats` (which returns\nper-operation call counts via `xApiCallsByOperation`) to compute exact\ncost attribution by X action.\n","properties":{"currency":{"type":"string","example":"USD"},"markup":{"type":"string","example":"0%","description":"Always 0%, because 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,\n`x_api_200` for $0.200). The first three keys map to the\nlegacy `xApiCalls` aggregate; new tiers (e.g. `x_api_200`\nfor the URL tier added April 2026) are surfaced here but\nnot in the legacy shape.\n","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":"Metering `event_type` emitted when this operation runs."},"displayName":{"type":"string","example":"X API: Posts Read","description":"Human-readable label shown on 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\n$0.005, `x_api_200` for $0.200). Useful for grouping operations\nby price in dashboards.\n","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:\n  * `always`: every call is metered\n  * `analytics_optin`: only when the X account has analytics enabled\n  * `inbox_optin`: only when the X account has inbox sync enabled\n  * `absorbed`: Zernio eats the cost, never billed\n"}}}}}},"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, X)"},"clicks":{"type":"integer","example":0},"views":{"type":"integer","example":0},"follows":{"type":["integer","null"],"example":0,"description":"Instagram feed posts and stories only: organic accounts that started following from this post. Null on Instagram Reels and non-Reels video, where Meta does not expose this metric for the media. 0 for 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."},"reelsSkipRate":{"type":"number","example":48.8,"description":"Instagram Reels only: percentage (0-100) of initial views that skipped the reel within its first 3 seconds, as reported by Meta. Meta labels the metric estimated and in development, so it can move between syncs. 0 for non-Reels media and other platforms. When a post is published to several accounts, the aggregate is weighted by views."},"completionRate":{"type":"number","example":0,"description":"TikTok accounts connected through the TikTok for Business app only: share of viewers who watched the video to the end, 0 to 1, as TikTok reports it (T+24-48h, only for posts active in the last 7 days). 0 for other platforms. When a post is published to several accounts, the aggregate is weighted by views."},"profileViews":{"type":"integer","example":0,"description":"TikTok accounts connected through the TikTok for Business app only: profile views from users who reached the profile through this post (T+24-48h). 0 for other platforms."},"reposts":{"type":"integer","example":0,"description":"Instagram accounts connected with Facebook Login only: reposts of the media by other users, minus deleted reposts, on feed posts, reels and stories. Meta does not expose this metric for accounts connected with Instagram Login, so those always report 0. 0 for other platforms, including Threads, where reposts are counted in shares instead."},"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":6.59,"description":"Percentage, rounded to 2 decimals: (likes + comments + shares + saves) / (impressions or reach or views) * 100. Clicks and follows are never counted. The denominator is the FIRST of impressions, reach, views that is non-zero, so it is not the same basis on every post: a post with impressions divides by impressions, one without falls back to reach, then to views. If you need a single consistent basis (e.g. interactions / reach), compute it from the raw fields above. The engagementRate on the LinkedIn account endpoints is a different formula."},"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","null"],"format":"uri","description":"'Direct URL to the media file. Null when the platform withholds it: check mediaStatus before downloading. Instagram omits the video file for Reels it flags as containing copyrighted material (its docs name audio as the usual cause), so type stays \"video\" while the file is permanently unreachable.'"},"thumbnail":{"type":["string","null"],"format":"uri","description":"Thumbnail URL (same as url for images). Still present when url is null."},"altText":{"type":"string","description":"Accessibility alt text set on the media, when present."},"mediaStatus":{"type":"string","enum":["available","unavailable"],"description":"unavailable means the media file could not be retrieved (url is null or, for LinkedIn videos, a cover image standing in for the file). available or absent means the file is available at url (older synced items omit the field)."},"unavailableReason":{"type":"string","enum":["platform_withheld"],"description":"Why the file is missing. platform_withheld means the platform declined to return it and retrying will not help."}}}},"mediaProductType":{"type":"string","description":"Instagram only: the platform media product type (e.g. FEED, REELS, STORY, AD). Absent when the platform did not report it."},"isAiGenerated":{"type":"boolean","description":"Instagram only: whether Instagram labeled the media as AI-generated. Absent when the platform did not report it."},"isSharedToFeed":{"type":"boolean","description":"Instagram reels only: whether the reel is also shared to the main feed. Absent when the platform did not report it."},"mediaAudioType":{"type":"string","description":"Instagram only: audio type of the media (MUSIC or ORIGINAL_SOUND). Absent when the platform did not report it."}}},"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"},"isAd":{"type":"boolean","description":"True when this post's metrics include paid delivery, so organic reporting should exclude it. Set for LinkedIn dark posts and for TikTok posts that one of your TikTok ads promotes (Spark / boosted). TikTok exposes no ad flag of its own, so a video created by an uploaded-asset (non-Spark) TikTok ad is posted to the profile with a fresh organic id and cannot be detected: those still report as false."},"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."}}}},"mediaProductType":{"type":"string","description":"Instagram only: the platform media product type (e.g. FEED, REELS, STORY, AD). Absent when the platform did not report it."},"isAiGenerated":{"type":"boolean","description":"Instagram only: whether Instagram labeled the media as AI-generated. Absent when the platform did not report it."},"isSharedToFeed":{"type":"boolean","description":"Instagram reels only: whether the reel is also shared to the main feed. Absent when the platform did not report it."},"mediaAudioType":{"type":"string","description":"Instagram only: audio type of the media (MUSIC or ORIGINAL_SOUND). Absent when the platform did not report it."}}}},"pagination":{"$ref":"#/components/schemas/Pagination"},"accounts":{"type":"array","description":"Connected 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"}}},"AnalyticsDeltaEntry":{"type":"object","description":"One changed analytics snapshot. Metrics are the absolute values recorded at\n`syncedAt`, not the amount they moved by since the previous snapshot, so a later\nentry for the same `postId` always supersedes an earlier one.\n","required":["postId","accountId","profileId","platform","platformPostId","publishedAt","syncedAt","isDeleted","metrics"],"properties":{"postId":{"type":"string","description":"External post ID. The same identifier as `posts[]._id` in GET /v1/analytics."},"accountId":{"type":"string","description":"Account this post was published through"},"profileId":{"type":"string","description":"Profile the account belongs to"},"platform":{"type":"string","example":"youtube"},"platformPostId":{"type":"string","description":"Platform-side post ID (for example the YouTube video ID)"},"publishedAt":{"type":"string","format":"date-time","description":"When the post was published, ISO-8601 UTC"},"syncedAt":{"type":"string","format":"date-time","description":"When the sync cycle that produced this snapshot STARTED, ISO-8601 UTC. This\nis NOT the order entries arrive in and it is not a resume point: a slow cycle\nwrites its rows after a faster cycle that started later, so `syncedAt` can go\nbackwards between consecutive entries. Use `nextCursor` to resume.\n"},"isDeleted":{"type":"boolean","description":"True when the post was detected as deleted on the platform at this sync"},"metrics":{"type":"object","description":"Metrics a platform does not report are 0, not absent.","required":["impressions","reach","likes","comments","shares","saves","sends","clicks","views","follows","igReelsAvgWatchTime","igReelsVideoViewTotalTime","reposts","reelsSkipRate","completionRate","profileViews"],"properties":{"impressions":{"type":"integer"},"reach":{"type":"integer"},"likes":{"type":"integer"},"comments":{"type":"integer"},"shares":{"type":"integer"},"saves":{"type":"integer"},"sends":{"type":"integer"},"clicks":{"type":"integer"},"views":{"type":"integer"},"follows":{"type":"integer","description":"Follows attributed to this post (Instagram)"},"igReelsAvgWatchTime":{"type":"integer","description":"Instagram Reels average watch time, in milliseconds"},"igReelsVideoViewTotalTime":{"type":"integer","description":"Instagram Reels total watch time, in milliseconds"},"reposts":{"type":"integer"},"reelsSkipRate":{"type":"number","description":"Instagram Reels skip rate, 0 to 1"},"completionRate":{"type":"number","description":"TikTok business lane: share of viewers who watched to the end, 0 to 1"},"profileViews":{"type":"integer","description":"TikTok business lane: profile views attributed to the post"}}}}},"AnalyticsDeltaResponse":{"type":"object","required":["data","nextCursor","hasMore"],"properties":{"data":{"type":"array","description":"Changed snapshots, oldest first, in the order the feed received them. Empty on\nthe bootstrap call (no `cursor` supplied) and whenever nothing has changed\nsince your cursor.\n","items":{"$ref":"#/components/schemas/AnalyticsDeltaEntry"}},"nextCursor":{"type":"string","description":"Cursor to send on the next call. ALWAYS present, including on an empty page,\nso you always have something to advance with, and it never moves backwards.\nOpaque: pass it back verbatim, and do not parse, construct or compare cursors.\n","example":"v1.WyIyMDI2LTA5LTAxIDE3OjEyOjA0IiwiNjVmMWMwYTllMmI1YWYwMDEyYWIzNGNkIl0"},"hasMore":{"type":"boolean","description":"True when more changes are already waiting past `nextCursor`, so call again\nimmediately. False means you are caught up: keep `nextCursor` and poll again\nlater. This feed never ends, so `hasMore: false` does NOT mean `nextCursor`\nis null.\n"}}},"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 a percentage rounded to 2 decimals: (reactions + comments + shares + saves + sends) / impressions * 100. Clicks are not counted, and there is no fallback denominator, so this is 0 whenever impressions is 0. This is NOT the same formula as PostAnalytics.engagementRate on GET /v1/analytics."}}},"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"}}},"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"}}},"TikTokDryRunVerdict":{"type":"object","required":["dryRun","canPublish","tiktok"],"properties":{"dryRun":{"type":"boolean","description":"Always true on this response"},"canPublish":{"type":"boolean","description":"True only when every evaluated TikTok account can publish now"},"tiktok":{"type":"array","description":"One verdict per `tiktok` entry in the request, in request order","items":{"type":"object","required":["accountId","canPublish","reason"],"properties":{"accountId":{"type":"string"},"canPublish":{"type":"boolean"},"alreadyPostedToday":{"type":"boolean","description":"Whether this account already published a Direct Post today (an account that did can keep publishing). Absent for Creator Inbox drafts and when no per-user limit applies."},"reason":{"type":"string","description":"Human-readable explanation of the verdict"}}}}}},"PostCreateResponse":{"type":"object","properties":{"message":{"type":"string"},"post":{"$ref":"#/components/schemas/Post"},"warnings":{"type":"array","description":"Advisory notices about a post that was still created: media truncated for a platform, a recycling caveat, or a field that was ignored because it sat outside platforms[].platformSpecificData. Absent when there are none.","items":{"type":"string"}}}},"PostUpdateResponse":{"type":"object","properties":{"message":{"type":"string"},"post":{"$ref":"#/components/schemas/Post"},"warnings":{"type":"array","items":{"type":"string"}}}},"PostPublishIncompleteResponse":{"type":"object","description":"Body of the 207 returned by createPost and updatePost when the post was saved but the inline publish did not fully succeed. Read `post.status` to tell the three outcomes apart.","properties":{"post":{"$ref":"#/components/schemas/Post"},"message":{"type":"string","description":"Human-readable summary of the publish outcome."},"error":{"type":"string","description":"Present when no platform published. Absent on a partial success. Informational only; the per-platform detail is in `platformResults` and in `post.platforms[]`."},"platformResults":{"type":"array","description":"Per-platform outcome of the publish attempt. Omitted when the attempt aborted before producing per-platform results (for example the post was already being processed); read `post.platforms[]` in that case.","items":{"type":"object","required":["platform","status","error"],"properties":{"platform":{"type":"string","description":"Platform slug, matching `post.platforms[].platform`."},"status":{"type":"string","example":"failed","description":"Per-platform status: pending, processing, published, failed, cancelled, uploading."},"error":{"type":["string","null"],"description":"Failure detail for this platform, or null when it did not fail."}}}},"warnings":{"type":"array","description":"Advisory notices about the post that was still created. Absent when there are none.","items":{"type":"string"}}}},"PostDeleteResponse":{"type":"object","properties":{"message":{"type":"string"}}},"PostRetryResponse":{"type":"object","properties":{"message":{"type":"string"},"post":{"$ref":"#/components/schemas/Post"}}},"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","null"],"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"}}},"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"}}},"AdsListResponse":{"type":"object","properties":{"ads":{"type":"array","items":{"$ref":"#/components/schemas/Ad"}},"pagination":{"$ref":"#/components/schemas/Pagination"},"backfillPending":{"type":"boolean","description":"Present and true while historical data is being backfilled."}}},"AdTreeResponse":{"type":"object","properties":{"campaigns":{"type":"array","items":{"$ref":"#/components/schemas/AdTreeCampaign"}},"pagination":{"$ref":"#/components/schemas/Pagination"},"backfillPending":{"type":"boolean","description":"Present and true while historical data is being backfilled."}}},"AdsTimelineResponse":{"type":"object","properties":{"backfillPending":{"type":"boolean","description":"Present and true while historical data is being backfilled."},"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 to 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)."},"allConversions":{"type":"number","description":"All conversions, including actions excluded from the Conversions column (Google metrics.all_conversions). 0 on platforms without the concept."},"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."}}}}}},"AdNegativeKeywordList":{"type":"object","properties":{"id":{"type":"string","pattern":"^\\d+$","description":"Google shared set id."},"resourceName":{"type":"string","description":"Google shared set resource name."},"name":{"type":"string","description":"List name."},"memberCount":{"type":"integer","description":"Number of keywords in the list."},"referenceCount":{"type":"integer","description":"Number of resources referencing the list."}}},"AdNegativeKeywordListKeyword":{"type":"object","properties":{"criterionId":{"type":"string","pattern":"^\\d+$","description":"Google shared criterion id."},"text":{"type":"string","description":"Keyword text."},"matchType":{"type":"string","enum":["broad","phrase","exact"]}}},"CampaignAnalyticsResponse":{"type":"object","properties":{"backfillPending":{"type":"boolean","description":"Present and true while historical data is being backfilled."},"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)."},"budget":{"anyOf":[{"$ref":"#/components/schemas/AdCampaignBudget"},{"type":"null"}],"description":"Google only. Latest synced campaign budget, or null before sync."},"currency":{"type":["string","null"],"description":"ISO 4217 code of the ad account (e.g. USD, THB). All money values in `summary` and `daily` are in this currency."}}},"analytics":{"type":"object","properties":{"summary":{"allOf":[{"$ref":"#/components/schemas/AdMetrics"},{"type":"object","properties":{"searchImpressionShare":{"type":["number","null"],"description":"Google only. Date-range ratio, not a percentage. Null when unavailable; Google's threshold sentinel values are preserved."},"searchBudgetLostImpressionShare":{"type":["number","null"],"description":"Google only. Date-range ratio, not a percentage. Null when unavailable; Google's threshold sentinel values are preserved."},"searchRankLostImpressionShare":{"type":["number","null"],"description":"Google only. Date-range ratio, not a percentage. Null when unavailable; Google's threshold sentinel values are preserved."},"searchTopImpressionShare":{"type":["number","null"],"description":"Google only. Date-range ratio, not a percentage. Null when unavailable; Google's threshold sentinel values are preserved."},"searchAbsoluteTopImpressionShare":{"type":["number","null"],"description":"Google only. Date-range ratio, not a percentage. Null when unavailable; Google's threshold sentinel values are preserved."}}}]},"impressionShareCache":{"type":"object","description":"Google only. Cache status of the single date-range impression-share query.","properties":{"cachedAt":{"type":["string","null"],"format":"date-time"},"stale":{"type":"boolean"}}},"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"}}}}}}},"AdAnalyticsResponse":{"type":"object","properties":{"backfillPending":{"type":"boolean","description":"Present and true while historical data is being backfilled."},"ad":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":"string"},"platform":{"type":"string"},"status":{"type":"string"},"currency":{"type":["string","null"],"description":"ISO 4217 code of the ad account that owns this ad (e.g. USD, THB, INR). All money values in `summary` and `daily` are in this currency. Null only on legacy ads synced before currency was persisted."}}},"analytics":{"type":"object","properties":{"summary":{"$ref":"#/components/schemas/AdMetrics"},"daily":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/AdMetrics"},{"type":"object","properties":{"date":{"type":"string","format":"date"}}}]}},"breakdowns":{"type":"object","additionalProperties":{"type":"array","items":{"type":"object"}}}}}}},"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."},"allConversions":{"type":"number","description":"All conversions, including actions excluded from the Conversions column (Google metrics.all_conversions). 0 on platforms without the concept."},"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, ...), covering both engagement and conversion events. TikTok: pixel conversions (purchase, add_to_cart, initiate_checkout, view_content, complete_payment, lead) plus the paid-engagement family (follow, post_reaction for paid likes, comment, share). Follow is how FOLLOWERS-goal campaigns report their result. 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.5}},"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."},"costPerAction":{"type":"object","additionalProperties":{"type":"number"},"description":"Derived `spend / actions[type]` for every action type with a non-zero count, in ad-account native currency. Same keys as `actions`. Rounded to 4 decimals because cheap actions cost well under a cent. Recomputed from summed spend + counts at every rollup level. Empty object when spend is 0 or no actions are reported.","example":{"link_click":0.1052,"offsite_conversion.fb_pixel_purchase":4.0114}},"outboundClicks":{"type":"integer","description":"Clicks leading off Meta's surfaces to the advertiser's destination. Meta-only; other platforms report 0."},"outboundClicksCtr":{"type":"number","description":"Derived `outboundClicks / impressions * 100`, recomputed from sums at every rollup level."},"inlineLinkClicks":{"type":"integer","description":"In-session link clicks. Differs from the attributed `link_click` count in `actions`/`engagementBreakdown.linkClicks`, which uses the attribution window. Meta-only."},"inlineLinkClickCtr":{"type":"number","description":"Derived `inlineLinkClicks / impressions * 100`, recomputed from sums at every rollup level."},"uniqueClicks":{"type":"integer","description":"People who clicked at least once. NOT additive: summed across days/children it overcounts people who clicked on multiple days or ads, so treat rollups as an upper bound (same caveat as `reach`). Meta-only."},"uniqueCtr":{"type":"number","description":"Derived `uniqueClicks / impressions * 100` (NOT Meta's reach-based unique_ctr). Inherits the non-additivity caveat of `uniqueClicks`."},"videoPlayActions":{"type":"integer","description":"Number of times the video started playing, summed over the date range and across children at ad-set/campaign level. 0 for non-video ads. Sources: Meta `video_play_actions`, TikTok `video_play_actions`."},"video30SecWatchedActions":{"type":"integer","description":"Views of at least 30 seconds (or to the end, for shorter videos). Sources: Meta `video_30_sec_watched_actions` (Meta only)."},"videoThruplayWatchedActions":{"type":"integer","description":"ThruPlays (watched to completion, or at least 15 seconds). Sources: Meta `video_thruplay_watched_actions` (Meta only)."},"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). Sources: Meta `video_p25_watched_actions`, TikTok `video_views_p25`."},"videoP50WatchedActions":{"type":"integer","description":"Views reaching 50% of the video's length. Sources: Meta `video_p50_watched_actions`, TikTok `video_views_p50`."},"videoP75WatchedActions":{"type":"integer","description":"Views reaching 75% of the video's length. Sources: Meta `video_p75_watched_actions`, TikTok `video_views_p75`."},"videoP95WatchedActions":{"type":"integer","description":"Views reaching 95% of the video's length. Sources: Meta `video_p95_watched_actions` (Meta only)."},"videoP100WatchedActions":{"type":"integer","description":"Views reaching 100% of the video's length. Sources: Meta `video_p100_watched_actions`, TikTok `video_views_p100`."},"videoAvgTimeWatchedActions":{"type":"number","description":"Average seconds watched per play. Aggregated over date ranges and across children as a play-weighted average (total watch time / total plays), never a plain average of averages. Sources: Meta `video_avg_time_watched_actions`, TikTok `average_video_play`."},"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 (ThruPlay is Meta-only)."},"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`\nso you never have to parse action-type strings yourself.\n\nMeta reports one event under several action types at once\n(`offsite_conversion.fb_pixel_purchase`, `omni_purchase`, `purchase`, …).\nEach field below takes the FIRST family member present rather than\nsumming them, which is what makes these counts safe to add up. Summing\nthe raw `actions` keys yourself double or triple counts. The same\npriority order backs `conversions`, so a purchase-optimised campaign\nreports the identical number in `conversions` and `funnel.purchases`.\n\nEvery field is 0 when that step never fired. Populated for Meta ads;\nother platforms report a different action taxonomy and generally leave\nthese at 0 (read `actions` for those). At ad-set and campaign level each\nstep is summed from its per-ad values.\n","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.\n\n`engagement` is not the sum of these: Meta's own\n`post_engagement` and `page_engagement` totals already contain the\nindividual interactions, and all of them are counted into `engagement`.\nUse these fields when you need a specific interaction, and `engagement`\nonly as the coarse total it has always been.\n\nPopulated for Meta and, since 2026-08, TikTok (`reactions` = paid\nlikes, `comments`, `shares`; TikTok's `follow` count lives in\n`actions.follow`, not here). Other platforms leave these at 0.\nTikTok history note: paused TikTok ads are not re-synced, so\ncampaigns that ended before the rollout keep 0s here.\n","properties":{"postEngagement":{"type":"integer","description":"Meta's own post-engagement total (`post_engagement`). Meta-only."},"pageEngagement":{"type":"integer","description":"Meta's own page-engagement total (`page_engagement`). Meta-only."},"reactions":{"type":"integer","description":"Reactions on the ad's post (`post_reaction`). For TikTok these are its paid likes."},"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`; TikTok under `share`."},"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 count in the sibling `inlineLinkClicks` field."}}},"AdDailyMetrics":{"description":"One day of metrics. Same fields as `AdMetrics` plus the `date` they\napply to. Returned inside a node's `daily[]` when `GET /v1/ads/tree` is\ncalled with `timeIncrement=1`. Rate metrics (ctr/cpc/cpm/costPerConversion/\nroas/videoAvgTimeWatchedActions) are recomputed per day from that day's\nsums, so summing the additive fields across a node's `daily[]` reproduces\nits aggregated `metrics` total. `reach` is the exception: on Meta and\nTikTok the aggregated total is de-duplicated across the range, so daily\nreach does not sum to it. Do NOT sum or plain-average\n`videoAvgTimeWatchedActions` across days: the range value is the\nplay-weighted average of the daily values.\n","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\nTikTok's agency container: one BC owns N advertisers (ad accounts). Most solo\nadvertisers don't have one; the agency token uses BCs to roll up multi-client access.\n","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.\n`null` when the BC asset walk returned empty or failed (typical for\nagency apps without full BC asset read scope), distinct from `0`,\nwhich would imply the BC genuinely has no advertisers.\n","example":23}}},"ValueRuleCriterion":{"type":"object","additionalProperties":false,"required":["criteriaType","operator","criteriaValues","criteriaValueTypes"],"description":"One matching condition inside a value rule. `criteriaValues` and `criteriaValueTypes`\nare POSITIONALLY paired: same length, same order.\n","properties":{"id":{"type":"string","description":"Platform criterion id. Echo it on `PUT` to KEEP this criterion, omit it to CREATE a\nnew one. A criterion left out of the array entirely is DELETED.\n"},"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,\nPHONE_CALL, WEBSITE) is accepted even though Meta's own enum table omits it.\n"},"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`;\na range whose upper bound is 65 is NOT allowed (use `18+` instead of `18-65`).\n`LOCATION` takes Targeting-Search keys: a two-letter country code for\n`LOCATION_COUNTRY`, a numeric key for region / city / comScore market.\n`AUDIENCE_LABEL` takes labels such as `HIGH_VALUE`, which are applied to a Custom\nAudience in Ads Manager: there is no API to provision them, so they are passed\nthrough unvalidated.\n"},"criteriaValueTypes":{"type":"array","minItems":1,"items":{"type":"string"},"description":"One entry per `criteriaValues` entry, in the same order. The literal `\"NONE\"` for\nevery criteriaType except `LOCATION`, which uses `LOCATION_COUNTRY`,\n`LOCATION_REGION`, `LOCATION_CITY` or `LOCATION_COMSCORE_MARKET` and MAY mix them\nwithin one criterion. `LOCATION_DMA` was replaced by `LOCATION_COMSCORE_MARKET` on\n2026-06-22 and is rejected by this API.\n"}}},"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\nrule adjusts the bid for an overlapping audience, so the order is semantic.\n","properties":{"id":{"type":"string","description":"Platform rule id. Echo it on `PUT` to KEEP this rule, omit it to CREATE a new one.\nA rule left out of the array entirely is DELETED.\n"},"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.\n0 is out of range on both.\n"},"status":{"type":"string","description":"Meta returns `ACTIVE` here but documents no enum for the field. Treat it as a\npassthrough: echo whatever the `GET` returned, and do not synthesize values.\n"},"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\n`valueRuleSetId`. Limits: 6 sets per ad account, 10 rules per set, 4 criteria per rule.\n","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)\noverrides campaign-level. Cross-field rules:\n  - `LOWEST_COST_WITHOUT_CAP` (default): auto-bid, forbids `bidAmount` and `roasAverageFloor`.\n  - `LOWEST_COST_WITH_BID_CAP` / `COST_CAP`: require `bidAmount` (whole currency units).\n  - `LOWEST_COST_WITH_MIN_ROAS`: requires `roasAverageFloor` (decimal multiplier, 2.0 = 2.0x).\nSource: facebook-business-sdk-codegen api_specs/specs/enum_types.json (`AdSet_bid_strategy`,\n`Campaign_bid_strategy`).\n"},"PortfolioBidStrategy":{"type":"object","description":"A Google Ads portfolio bid strategy: a named bidding strategy shared across campaigns, with its R.130 report metrics over the queried date range.","properties":{"id":{"type":"string","description":"Numeric bid strategy id; pass as portfolioBidStrategyId or in the {strategyId} path."},"name":{"type":"string"},"type":{"type":"string","enum":["TARGET_CPA","TARGET_ROAS","MAXIMIZE_CONVERSIONS","MAXIMIZE_CONVERSION_VALUE"]},"status":{"type":"string","description":"ENABLED or REMOVED."},"campaignCount":{"type":"integer","description":"Number of campaigns currently attached."},"clicks":{"type":"integer"},"cost":{"type":"number","description":"Cost in the account's currency units (converted from micros)."},"costPerConversion":{"type":"number","description":"Cost per conversion in the account's currency units."},"impressions":{"type":"integer"},"averageCpc":{"type":"number","description":"Average CPC in the account's currency units."},"conversions":{"type":"number"},"targetCpa":{"type":["number","null"],"description":"Current target, in the account's currency units. Null for a ROAS-family type (TARGET_ROAS, MAXIMIZE_CONVERSION_VALUE), or a Maximize type with no target set. Pre-fills the edit form's target field."},"targetRoas":{"type":["number","null"],"description":"Current target as a decimal multiplier (2.0 = 2.0x). Null for a CPA-family type (TARGET_CPA, MAXIMIZE_CONVERSIONS), or a Maximize type with no target set."}}},"CampaignBidding":{"type":"object","description":"A Google campaign's current bidding, mapped onto the same triplet PUT /v1/ads/campaigns/{campaignId} accepts.","properties":{"channel":{"type":"string","enum":["SEARCH","DISPLAY"],"description":"campaign.advertising_channel_type. COST_CAP's underlying Google field differs by channel; see bidStrategy on PUT."},"biddingStrategyType":{"type":"string","description":"Google's raw enum: MAXIMIZE_CONVERSIONS, TARGET_CPA, MAXIMIZE_CONVERSION_VALUE, TARGET_ROAS, TARGET_SPEND, MANUAL_CPC, TARGET_IMPRESSION_SHARE, or another Google adds later."},"bidSpec":{"type":["object","null"],"description":"Null when the campaign is on a strategy PUT does not model (Manual CPC, Target Impression Share, ...); show biddingStrategyType instead in that case.","properties":{"bidStrategy":{"$ref":"#/components/schemas/BidStrategy"},"bidAmount":{"type":"number","description":"Whole currency units. Present for COST_CAP and LOWEST_COST_WITH_BID_CAP, and omitted when the campaign is on a bare TARGET_SPEND with no CPC ceiling set."},"roasAverageFloor":{"type":"number","description":"Decimal ROAS multiplier (2.0 = 2.0x). Present for LOWEST_COST_WITH_MIN_ROAS."},"portfolioBidStrategyId":{"type":"string","description":"Present alone (bidStrategy omitted) when the campaign is on a portfolio strategy; see portfolio."}}},"portfolio":{"type":["object","null"],"description":"Set only when the campaign is on a portfolio bid strategy (campaign.bidding_strategy); null otherwise.","properties":{"id":{"type":"string"},"name":{"type":"string"}}},"cachedAt":{"type":["string","null"],"format":"date-time","description":"When this data was fetched from Google. Null when it was never served from cache."},"stale":{"type":"boolean","description":"True when Google's daily API quota was exhausted and this is the last successful fetch, not a live read."}}},"AdCampaignBudget":{"allOf":[{"$ref":"#/components/schemas/AdBudget"},{"type":"object","properties":{"amountMicros":{"type":"string","pattern":"^\\d+$","description":"Google only. Exact decimal micros; DAILY uses amount_micros and CUSTOM_PERIOD uses total_amount_micros."},"explicitlyShared":{"type":["boolean","null"],"description":"Google only. True for a shared budget; null when unavailable. Shared writes require allowSharedBudgetUpdate=true; unknown sharing status cannot be overridden."},"resourceName":{"type":["string","null"],"description":"Google only. campaign_budget.resource_name, or null when unavailable."},"deliveryMethod":{"type":["string","null"],"description":"Google only. campaign_budget.delivery_method, typically STANDARD, or null when unavailable."}}}]},"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"]}}},"AdPromotedObject":{"type":"object","description":"What the ad optimises against. Behaviour depends on the platform.\n\n**Meta**: forwarded to the ad set's `promoted_object` (snake-cased).\nFor `goal: app_promotion`, it is also sent on the campaign only when\n`isSkadnetworkAttribution: true`. Plain Android app installs keep the\nexisting campaign payload, with the promoted object only on the ad set.\nPOST /v1/ads/campaigns forwards this object only for that explicit SKAN flag.\nRequired for goals whose ad-set optimization_goal points at a specific\nevent/page/app (without it Meta rejects the ad-set create with\n`error_subcode: 1815430` \"Please select a promoted object for your ad set\"):\n  - `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.\n  - `goal: app_promotion` (APP_INSTALLS): requires `applicationId` + `objectStoreUrl`\n  - `goal: lead_generation` (LEAD_GENERATION): `pageId` is auto-filled from the connected Page when omitted\n\nOther Meta goals (engagement, traffic, awareness, video_views) ignore this field.\n\n**TikTok**: used by `goal: conversions` and the Smart+ goals (`smartPlus: true`).\n  - `pixelId` maps to the ad group's `pixel_id`. Required: a TikTok website-conversion\n    ad group without a pixel is rejected with `40002: Please select a pixel`.\n  - `customEventType` maps to the ad group's `optimization_event` (the pixel event to\n    optimise for). Optional on the regular conversions flow, required on Smart+.\n    See the `customEventType` field below for the valid TikTok codes.\n  - `applicationId` (Smart+ `goal: app_promotion` only) maps to the ad group's `app_id`:\n    the App ID of an app registered on the TikTok Ads account (Assets → Events →\n    App Events). Install optimization needs the app's MMP tracking configured.\n\nThe remaining `promotedObject.*` fields are Meta-only. Platforms other than\nMeta and TikTok ignore `promotedObject` entirely.\n","properties":{"pixelId":{"type":"string","description":"Pixel ID. **Meta:** Facebook Pixel ID, required for `goal: conversions`.\nRequires `customEventType` alongside it; Meta rejects any promoted_object\ncarrying `pixel_id` without `custom_event_type` (error_subcode 1885014),\neven when `customConversionId` is also present.\n**TikTok:** TikTok Pixel ID, required for `goal: conversions`.\nTo discover the pixels an ad account can use, call\n`GET /v1/accounts/{accountId}/tracking-tags?adAccountId=act_...` (each entry\ncarries `kind` and `ownerAdAccountId`), or\n`GET /v1/accounts/{accountId}/conversion-destinations`. Note this is a\ndifferent resource from `GET /v1/ads/{adId}/tracking-tags`, which reads an\nad's click-URL params (`url_tags`), not pixels.\n"},"customEventType":{"type":"string","description":"The event the campaign/ad group optimises against.\n\n**Meta:** standard event like `PURCHASE`, `LEAD`, `COMPLETE_REGISTRATION`,\n`ADD_TO_CART`. Uppercased internally so callers can pass any case. Required\nfor `goal: conversions`.\n\n**TikTok:** an `optimization_event` code (UPPER_SNAKE, not Meta's vocabulary\nand not PascalCase), OR the exact event name shown in TikTok Events Manager\n(auto-resolved to its code). Must be one of the event types your TikTok\nPixel tracks; custom events are not optimizable. Current taxonomy:\n`SHOPPING` (Purchase), `ON_WEB_CART` (Add to Cart), `INITIATE_ORDER`\n(Initiate Checkout), `FORM` (Lead), `ON_WEB_REGISTER` (Complete\nRegistration), `ON_WEB_DETAIL` (View Content). `ON_WEB_ORDER` is\ndeprecated. On rejection the error lists the event types your pixel\nactually tracks. Optional for `goal: conversions`.\n"},"customEventStr":{"type":"string","description":"Meta only. Pixel custom-event name to optimise against (Meta's\n`custom_event_str`), exactly as it appears in Events Manager and in your\nCAPI payloads (case-sensitive, not uppercased). Requires\n`customEventType: OTHER`, and `OTHER` requires this field (400 either way).\nThe same as picking a custom event in Ads Manager's conversion-event\ndropdown. For rule-based Custom Conversions use `customConversionId`\ninstead.\n"},"pageId":{"type":"string","description":"Facebook Page ID. Used by `goal: lead_generation`. Auto-filled from the\nconnected Page when omitted.\n"},"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\nevent. Accepted alone by this API, without `pixelId` or `customEventType`.\nIf `pixelId` is also sent, `customEventType` is still required on the\npromoted_object (Meta rejects `pixel_id` without `custom_event_type`,\nerror_subcode 1885014).\n"},"productCatalogId":{"type":"string","description":"Optional catalog ID. If supplied with productSetId, the set must belong to this catalog. A catalog ID cannot replace productSetId."},"productSetId":{"type":"string","description":"Meta product SET ID from GET /v1/ads/catalogs/{catalogId}/product-sets. Zernio checks that the token can read the set and its product_catalog before creation. A catalog ID or inaccessible set returns a precise 400 naming promotedObject.productSetId. A mismatch with productCatalogId names promotedObject.productCatalogId."},"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},"TargetingSpec":{"type":"object","description":"Normalized, platform-agnostic ad-targeting spec. Every field is optional, an\nempty object targets the platform's default broadest audience. Field names are\ncamelCase and identical across `POST /v1/ads/create` (the `targeting` object),\n`POST /v1/ads/targeting/reach-estimate`, and `saved_targeting` audiences, so a\nspec resolved once can be reused verbatim.\n\nEntity ids (`regions[].key`, `cities[].key`, `zips[].key`, `metros[].key`,\n`interests[].id`, `behaviors[].id`) are the platform's opaque identifiers\nresolved via `GET /v1/ads/targeting/search`. A spec is therefore meaningful only\nfor the platform it was built against, except the portable fields (`countries`,\n`ageMin`/`ageMax`, `gender`, `incomeTier`, `languages`) which carry across\nplatforms. Fields a platform cannot honour are rejected at create time with\n`INVALID_FIELD_VALUE` naming the offending field (not silently dropped).\n","properties":{"userOs":{"type":"array","minItems":1,"items":{"type":"string","minLength":1},"description":"Meta only. Operating systems and version ranges, such as iOS_ver_14.0_and_above or Android. Emitted as user_os. May also be supplied inside targeting."},"userDevice":{"type":"array","minItems":1,"items":{"type":"string","minLength":1},"description":"Meta only. Device models such as iPhone. Emitted as user_device. May also be supplied inside targeting."},"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"}}}},"countryGroups":{"type":"array","description":"Meta only. Continents and trade blocs (`geo_locations.country_groups`),\nfor targeting a whole region without listing its countries. Combines\nwith `countries` rather than replacing it, and is also accepted under\n`excludedLocations`. Discoverable via\n`GET /v1/ads/targeting/search?dimension=geo&geoType=country_group`.\n","items":{"type":"string","enum":["africa","asia","europe","north_america","south_america","oceania","central_america","caribbean","eea","euro_area","nafta","mercosur","afta","apec","gcc","cisfta","emerging_markets","itunes_app_store","android_free_store","android_paid_store"]}},"customLocations":{"type":"array","description":"Point-radius (lat/lng) targeting (Meta custom_locations / Google proximity). Honoured on Meta and Google; ignored 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","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"}},"countryGroups":{"type":"array","description":"Meta only. Continents and trade blocs to exclude (`excluded_geo_locations.country_groups`).","items":{"type":"string","enum":["africa","asia","europe","north_america","south_america","oceania","central_america","caribbean","eea","euro_area","nafta","mercosur","afta","apec","gcc","cisfta","emerging_markets","itunes_app_store","android_free_store","android_paid_store"]}},"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,"description":"Minimum age. Applied on Meta, TikTok and Pinterest; ignored on Google, LinkedIn and X. Each platform clamps to its own range: Meta and Pinterest effectively cap at 65 (65 = 65+), TikTok maps up to 100. Pinterest has no under-18 bucket, so an ageMin below 18 starts at 18 there."},"ageMax":{"type":"integer","minimum":13,"maximum":100,"description":"Maximum age. Same per-platform application and clamping as ageMin."},"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\nexpress all four. Google maps only `top_10` (its INCOME_RANGE_90_UP); other\ntiers on Google, and any income tier on LinkedIn / X / Pinterest, are rejected.\nOn Meta, income/zip targeting requires the relevant `specialAdCategories` to be\nunset (housing/employment/credit ads cannot use it).\n"},"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"}}}},"workPositions":{"type":"array","description":"Meta only. Job title entities from /v1/ads/targeting/search?dimension=workPosition. Not interchangeable with the LinkedIn `jobTitles` URN fragments.","items":{"type":"object","required":["id"],"properties":{"id":{"type":"string"},"name":{"type":"string"}}}},"workEmployers":{"type":"array","description":"Meta only. Employer entities from /v1/ads/targeting/search?dimension=workEmployer.","items":{"type":"object","required":["id"],"properties":{"id":{"type":"string"},"name":{"type":"string"}}}},"workIndustries":{"type":"array","description":"Meta only. Work-industry entities from /v1/ads/targeting/search?dimension=workIndustry. Not interchangeable with the LinkedIn `industries` URN fragments.","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, or the full urn:li:industry:* URN from /v1/ads/targeting/search?dimension=industry."},"companySizes":{"type":"array","items":{"type":"string"},"description":"LinkedIn B2B only. Single-letter size codes (A to I), or the full urn:li:staffCountRange:* URN from /v1/ads/targeting/search?dimension=companySize."},"seniorities":{"type":"array","items":{"type":"string"},"description":"LinkedIn B2B only. Seniority URN id fragments, or the full urn:li:seniority:* URN from /v1/ads/targeting/search?dimension=seniority."},"jobFunctions":{"type":"array","items":{"type":"string"},"description":"LinkedIn B2B only. Function URN id fragments, or the full urn:li:function:* URN from /v1/ads/targeting/search?dimension=jobFunction."},"audienceInclude":{"type":"array","items":{"type":"string"},"description":"Platform audience IDs to include, as returned by GET /v1/ads/audiences (Meta custom audience ids, TikTok audience ids, Pinterest customer list ids, LinkedIn segment ids (the platformAudienceId from GET /v1/ads/audiences; Zernio resolves it to the targetable LinkedIn ad segment, an unknown id returns 400), Google user list ids, X custom audience ids). Not supported on OpenAI (400)."},"audienceExclude":{"type":"array","items":{"type":"string"},"description":"Platform audience IDs to exclude; same ID formats as audienceInclude. Not supported on OpenAI (400)."}}},"MetaCreativeFeatures":{"type":"object","additionalProperties":{"type":"string","enum":["OPT_IN","OPT_OUT"]},"propertyNames":{"pattern":"^[a-z0-9_]+$"},"description":"Meta Advantage+ creative enhancements. Map snake_case feature names to OPT_IN or OPT_OUT; Meta validates supported keys and unspecified features default to OPT_OUT. auto_promotion_tag is an Advantage+ enhancement, not the Ads Manager Promotion setting. The deprecated standard_enhancements bundle is rejected by Meta.","example":{"auto_promotion_tag":"OPT_IN"}},"MetaPromotion":{"type":["object","null"],"description":"Not supported. Meta validates creative_sourcing_spec.promotion_metadata_spec on the create call and then discards it, so a Promotion set through the Marketing API never reaches the creative. Any object is rejected with 400 invalid_field_value. Send null or omit the field, and set the Promotion on the ad in Ads Manager. Verified on 2026-09-11 across Graph v19.0 to v25.0 and every write path.","example":null},"GoogleSitelink":{"type":"object","required":["text","linkUrl"],"properties":{"text":{"type":"string","minLength":1,"maxLength":25},"linkUrl":{"type":"string","format":"uri"},"description1":{"type":"string","minLength":1,"maxLength":35},"description2":{"type":"string","minLength":1,"maxLength":35}}},"GoogleStructuredSnippet":{"type":"object","required":["header","values"],"properties":{"header":{"type":"string","enum":["Amenities","Brands","Courses","Degree programs","Destinations","Featured hotels","Insurance coverage","Models","Neighborhoods","Service catalog","Shows","Styles","Types"]},"values":{"type":"array","minItems":3,"maxItems":10,"items":{"type":"string","minLength":1,"maxLength":25}}}},"GoogleAssetUpdate":{"type":"object","required":["assetResourceName"],"properties":{"assetResourceName":{"type":"string","pattern":"^customers/\\d+/assets/\\d+$","description":"Asset resource name returned by a list operation. Must belong to the selected customer."},"sitelinkAsset":{"type":"object","properties":{"linkText":{"type":"string","minLength":1,"maxLength":25},"description1":{"type":"string","maxLength":35},"description2":{"type":"string","maxLength":35},"linkUrl":{"type":"string","format":"uri","description":"Alias for finalUrls with one URL. Do not supply both."}},"minProperties":1},"finalUrls":{"type":"array","items":{"type":"string","format":"uri"},"minItems":1},"calloutAsset":{"type":"object","required":["calloutText"],"properties":{"calloutText":{"type":"string","minLength":1,"maxLength":25}}},"structuredSnippetAsset":{"$ref":"#/components/schemas/GoogleStructuredSnippet"}},"description":"Supply fields for exactly one asset type per update. finalUrls may accompany sitelinkAsset. Shared asset edits affect every attachment using the asset."},"GooglePmaxAssetGroupInput":{"type":"object","additionalProperties":false,"required":["finalUrl","headlines","longHeadline","descriptions","businessName","images"],"description":"Google Performance Max creative assets. At least one description must be 60 characters or fewer. Texts within each list must be distinct.","properties":{"name":{"type":"string","minLength":1,"maxLength":128,"description":"Defaults to the request name."},"finalUrl":{"type":"string","format":"uri","pattern":"^https?://","description":"Required destination URL."},"headlines":{"type":"array","minItems":3,"maxItems":15,"uniqueItems":true,"items":{"type":"string","minLength":1,"maxLength":30}},"longHeadline":{"type":"string","minLength":1,"maxLength":90},"descriptions":{"type":"array","minItems":2,"maxItems":5,"uniqueItems":true,"description":"At least one description must be 60 characters or fewer.","items":{"type":"string","minLength":1,"maxLength":90}},"businessName":{"type":"string","minLength":1,"maxLength":25},"images":{"type":"object","additionalProperties":false,"required":["landscape","square","logo"],"description":"Public HTTP(S) image URLs. GIF, JPEG or PNG, at most 5120 KB per image. Google validates dimensions and aspect ratios.","properties":{"landscape":{"type":"array","minItems":1,"maxItems":20,"description":"Landscape marketing images. Aspect ratio 1.91:1, minimum 600 x 314 pixels.","items":{"type":"string","format":"uri","pattern":"^https?://"}},"square":{"type":"array","minItems":1,"maxItems":20,"description":"Square marketing images. Aspect ratio 1:1, minimum 300 x 300 pixels.","items":{"type":"string","format":"uri","pattern":"^https?://"}},"logo":{"type":"array","minItems":1,"maxItems":5,"description":"Required square logos. Aspect ratio 1:1, minimum 128 x 128 pixels.","items":{"type":"string","format":"uri","pattern":"^https?://"}}}},"youtubeVideoId":{"type":"string","pattern":"^[A-Za-z0-9_-]{11}$","description":"Optional existing YouTube video id. Google can generate video when omitted. Video uploads and arbitrary video URLs are not supported."}},"example":{"finalUrl":"https://zernio.com","headlines":["Schedule posts","One social API","Build with Zernio"],"longHeadline":"Schedule social content from your app with Zernio","descriptions":["Connect your social accounts.","Publish and manage social content through one API."],"businessName":"Zernio","images":{"landscape":["https://example.com/landscape.png"],"square":["https://example.com/square.png"],"logo":["https://example.com/logo.png"]}}},"GooglePmaxAssetGroupUpdate":{"type":"object","additionalProperties":false,"minProperties":1,"description":"Replacement assets for an existing Performance Max asset group, sent on PUT /v1/ads/{adId}.\nGoogle assets are immutable (AssetService only creates), so each field you send becomes new\nassets linked to the asset group, and the assets that role held are unlinked in the same\natomic request. Send one field or many; a field you omit is left untouched. Re-sending a\nvalue the asset group already carries is a no-op for that asset, not a re-upload.\nUnlinked assets stay in the account's asset library: Google has no asset delete.\nAt least one description must be 60 characters or fewer. Texts within each list must be distinct.\n","properties":{"finalUrl":{"type":"string","format":"uri","pattern":"^https?://","description":"Replaces the asset group's final URL."},"headlines":{"type":"array","minItems":3,"maxItems":15,"uniqueItems":true,"description":"Replaces every HEADLINE asset on the group.","items":{"type":"string","minLength":1,"maxLength":30}},"longHeadline":{"type":"string","minLength":1,"maxLength":90,"description":"Replaces the LONG_HEADLINE asset."},"descriptions":{"type":"array","minItems":2,"maxItems":5,"uniqueItems":true,"description":"Replaces every DESCRIPTION asset. At least one must be 60 characters or fewer.","items":{"type":"string","minLength":1,"maxLength":90}},"businessName":{"type":"string","minLength":1,"maxLength":25,"description":"Replaces the BUSINESS_NAME asset."},"images":{"type":"object","additionalProperties":false,"minProperties":1,"description":"Public HTTP(S) image URLs. Each role you send replaces that role's images; roles you omit stay. GIF, JPEG or PNG, at most 5120 KB per image.","properties":{"landscape":{"type":"array","minItems":1,"maxItems":20,"description":"Replaces MARKETING_IMAGE assets. Aspect ratio 1.91:1, minimum 600 x 314 pixels.","items":{"type":"string","format":"uri","pattern":"^https?://"}},"square":{"type":"array","minItems":1,"maxItems":20,"description":"Replaces SQUARE_MARKETING_IMAGE assets. Aspect ratio 1:1, minimum 300 x 300 pixels.","items":{"type":"string","format":"uri","pattern":"^https?://"}},"logo":{"type":"array","minItems":1,"maxItems":5,"description":"Replaces LOGO assets. Aspect ratio 1:1, minimum 128 x 128 pixels.","items":{"type":"string","format":"uri","pattern":"^https?://"}}}},"youtubeVideoIds":{"type":"array","minItems":1,"maxItems":5,"description":"Replaces YOUTUBE_VIDEO assets with existing YouTube video ids. Video uploads and arbitrary video URLs are not supported.","items":{"type":"string","pattern":"^[A-Za-z0-9_-]{11}$"}}},"example":{"headlines":["Schedule posts","One social API","Build with Zernio"],"images":{"landscape":["https://example.com/new-landscape.png"]}}},"GooglePmaxAssetGroup":{"type":"object","required":["id","resourceName","name","status","finalUrls","assets"],"properties":{"id":{"type":"string"},"resourceName":{"type":"string"},"name":{"type":"string"},"status":{"type":"string","description":"Asset-group status on Google. Campaign status independently controls delivery."},"finalUrls":{"type":"array","items":{"type":"string","format":"uri"}},"assets":{"type":"array","items":{"type":"object","required":["resourceName","fieldType","status"],"properties":{"resourceName":{"type":"string"},"fieldType":{"type":"string","description":"Google asset role, such as HEADLINE or LOGO."},"status":{"type":"string"},"text":{"type":"string"},"imageUrl":{"type":"string","format":"uri"},"youtubeVideoId":{"type":"string"}}}}}},"GoogleRsaHeadline":{"type":"object","required":["text"],"properties":{"text":{"type":"string","minLength":1,"maxLength":30},"pinnedField":{"type":"string","enum":["HEADLINE_1","HEADLINE_2","HEADLINE_3"],"description":"Optional fixed headline position. Omit to leave the asset unpinned."}}},"GoogleRsaDescription":{"type":"object","required":["text"],"properties":{"text":{"type":"string","minLength":1,"maxLength":90},"pinnedField":{"type":"string","enum":["DESCRIPTION_1","DESCRIPTION_2"],"description":"Optional fixed description position. Omit to leave the asset unpinned."}}},"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","page_likes","job_applicants"],"description":"Available goals vary by platform. Meta (Facebook/Instagram) supports all 10 (incl. `lead_conversion` = website pixel lead optimization, `catalog_sales` = Advantage+ catalog ads and `page_likes` = Page Likes conversion location under Engagement). TikTok supports engagement, traffic, awareness, video_views, lead_generation, conversions, app_promotion. LinkedIn supports all Meta goals except app_promotion / lead_conversion / catalog_sales / page_likes. X supports engagement, traffic, awareness, video_views, app_promotion. Pinterest supports only engagement, traffic, awareness, video_views. Google Ads supports only engagement, traffic, awareness (video_views is rejected at create with 422 FEATURE_NOT_AVAILABLE)."},"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`:\n\n- Meta: ad set `optimization_goal` (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION, LINK_CLICKS).\n- LinkedIn: the campaign's EFFECTIVE `optimizationTargetType`, refreshed from LinkedIn on every\n  sync rather than echoing what was passed on create. `NONE` means manual bidding, and it is a\n  real value, not missing data. Auto-bid values are MAX_IMPRESSION / MAX_CLICK / MAX_CONVERSION /\n  MAX_VIDEO_VIEW / MAX_LEAD / MAX_REACH; target-cost values are TARGET_COST_PER_CLICK /\n  TARGET_COST_PER_IMPRESSION / TARGET_COST_PER_VIDEO_VIEW; cost-cap values are the\n  CAP_COST_AND_MAXIMIZE_* family.\n","example":"OFFSITE_CONVERSIONS"},"costType":{"type":["string","null"],"description":"LinkedIn only. The campaign's EFFECTIVE cost model (billing event) as applied by LinkedIn,\nrefreshed on every sync rather than echoing what was passed on create. One of `CPM` (cost per\nthousand impressions), `CPC` (cost per click) or `CPV` (cost per video view). On LinkedIn this is\nthe axis that pairs with `bidAmount`; there is no `bidStrategy`. For campaign type\nSPONSORED_INMAILS, `CPM` bills as cost-per-send x 1000. `null` for non-LinkedIn ads.\n","example":"CPC"},"servingStatuses":{"type":"array","items":{"type":"string"},"description":"LinkedIn only. Why the parent campaign is (or is not) delivering, verbatim from LinkedIn.\nA campaign can report `status: ACTIVE` and still serve nothing; this array is what says so.\n\n- `[]` means no serving data: a non-LinkedIn ad, or a LinkedIn ad not yet re-synced.\n- `[\"RUNNABLE\"]` means the campaign is eligible to serve.\n- Anything else is a hold. Known values include ACCOUNT_SERVING_HOLD, ACCOUNT_TOTAL_BUDGET_HOLD,\n  ACCOUNT_END_DATE_HOLD, CAMPAIGN_START_DATE_HOLD, CAMPAIGN_END_DATE_HOLD,\n  CAMPAIGN_TOTAL_BUDGET_HOLD, CAMPAIGN_AUDIENCE_COUNT_HOLD, CAMPAIGN_GROUP_START_DATE_HOLD,\n  CAMPAIGN_GROUP_END_DATE_HOLD, CAMPAIGN_GROUP_TOTAL_BUDGET_HOLD, CAMPAIGN_GROUP_STATUS_HOLD and\n  STOPPED. The list is open on purpose, so treat unrecognized values as holds rather than errors.\n\nThe end-date and total-budget holds are terminal and surface as `status: completed`; the rest\nsurface as `status: paused`. A hold is not the only cause of zero delivery: with\nmanual, target-cost or cost-cap bidding, a `bidAmount` of 0 stops delivery while\n`servingStatuses` still reads `[\"RUNNABLE\"]`. Check `costType` / `bidAmount` /\n`optimizationGoal` as well.\n","example":["ACCOUNT_TOTAL_BUDGET_HOLD"]},"platformAdAccountName":{"type":["string","null"],"description":"Human-readable advertiser/account name (Meta `AdAccount.name`, TikTok\n`advertiser_name`, LinkedIn / X / Pinterest equivalents). Refreshed every\nsync so platform-side renames propagate within one cycle. `null` when the\nplatform doesn't return a name or the sync hasn't run yet.\n","example":"Zernio - previously Late"},"platformCreatedAt":{"type":["string","null"],"format":"date-time","description":"Platform-reported creation timestamp (Meta `created_time`, TikTok `create_time`).\nDistinct from `createdAt` which reflects when Zernio first synced the doc. To\nsort or filter by \"when the ad was actually created on the platform\", read this field.\n`null` for legacy ads synced before this field was added; aggregations fall back\nto `createdAt` in that case.\n"},"bidStrategy":{"anyOf":[{"$ref":"#/components/schemas/BidStrategy"},{"type":"null"}],"description":"Ad-set bid strategy (overrides campaign level on Meta). Populated for Meta and\nTikTok. TikTok's native `bid_type` is normalized to the cross-platform Meta enum:\n`BID_TYPE_NO_BID` -> `LOWEST_COST_WITHOUT_CAP`, `BID_TYPE_CUSTOM` ->\n`LOWEST_COST_WITH_BID_CAP`, deep_bid_type=MIN_ROAS or roas_bid>0 ->\n`LOWEST_COST_WITH_MIN_ROAS`, `BID_TYPE_MAX_CONVERSION` -> `LOWEST_COST_WITHOUT_CAP`.\n","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).\n\n- Meta source: `bid_amount` on the ad set (smallest-denomination int, decoded here). Populated\n  when bidStrategy is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`; `null` for auto-bid\n  (`LOWEST_COST_WITHOUT_CAP`).\n- TikTok source: priority order `bid_price` -> `conversion_bid_price` -> `deep_cpa_bid`\n  (whichever is set on the ad group). TikTok stores all three in whole currency units.\n- LinkedIn source: the campaign's EFFECTIVE `unitCost`, refreshed on every sync rather than\n  echoing what was passed on create. Its meaning depends on the bidding mode implied by\n  `optimizationGoal`: bid amount (manual), target cost, or cost cap. It pairs with `costType`,\n  NOT with `bidStrategy`, which LinkedIn does not have. A value of `0` is a real, delivery-\n  stopping configuration and not \"unset\", so do not gate this field on `bidStrategy` for\n  LinkedIn ads.\n\nSource: facebook-business-sdk-codegen api_specs/specs/AdSet.json (`bid_amount`).\n","example":5},"roasAverageFloor":{"type":["number","null"],"description":"Minimum ROAS as a decimal multiplier (2.0 = 2.0x ROAS). Populated when bidStrategy\nis `LOWEST_COST_WITH_MIN_ROAS`.\n\n- Meta source: decoded from `bid_constraints.roas_average_floor` (Meta stores as\n  fixed-point int × 10000; we return the decimal).\n- TikTok source: `roas_bid` on the ad group (already a decimal).\n\nSource: facebook-business-sdk-codegen api_specs/specs/AdCampaignBidConstraint.json.\n","example":2},"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":{"assetGroup":{"$ref":"#/components/schemas/GooglePmaxAssetGroupInput","description":"Initial Performance Max asset group input. Use the asset-groups endpoint for current Google assets."},"assetGroupResourceName":{"type":"string","description":"Google resource name of the created Performance Max asset group.","example":"customers/9122445560/assetGroups/123456789"},"headlines":{"type":"array","minItems":3,"maxItems":15,"items":{"$ref":"#/components/schemas/GoogleRsaHeadline"},"description":"Google RSA only. Replaces the complete headline list. No padding or truncation on update."},"descriptions":{"type":"array","minItems":2,"maxItems":4,"items":{"$ref":"#/components/schemas/GoogleRsaDescription"},"description":"Google RSA only. Replaces the complete description list. No padding or truncation on update."},"finalUrls":{"type":"array","minItems":1,"items":{"type":"string","format":"uri"},"description":"Google RSA only. Replaces final URLs. Omitted lists stay unchanged."},"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."},"creativeId":{"type":["string","null"],"description":"Meta ad creative id backing this ad. Reusable via existingCreativeId on POST /v1/ads/create."},"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, because Meta omits it for SHARE creatives. Use effectiveObjectStoryId instead."},"effectiveObjectStoryId":{"type":["string","null"],"description":"Meta `effective_object_story_id`, the `{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`, the 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.\nA superset of the ad-level `servingStatuses`: it repeats the inherited campaign, campaign\ngroup and account holds AND adds creative-only causes such as UNDER_REVIEW, REJECTED,\nPROCESSING, PROCESSING_FAILED, FORM_HOLD (lead-gen-form creatives),\nREFERRED_CONTENT_QUALITY_HOLD, JOB_POSTING_ON_HOLD and JOB_POSTING_INVALID (job ads).\nSome values are format-specific and will never appear on other ad formats. The list is\nopen, so treat unrecognized values as holds rather than errors.\n","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"},"whatsappPhoneNumber":{"type":"string","description":"Explicit E.164 WhatsApp number supplied when creating a Meta boost or messaging ad. Absent when omitted by the caller or on older records.","example":"+12025550123"},"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).\nFor ads created through Zernio this is the spec you supplied. For external ads (synced from\nMeta Ads Manager, `isExternal: true`) targeting lives at the ad set and isn't stored at ingest,\nso on the first `GET /v1/ads/{adId}` Zernio resolves it live from Meta and caches it on the ad;\nthe value is then Meta's raw `targeting` shape (snake_case, e.g. `geo_locations`, `age_min`),\nthe same object Ads Manager shows. May be absent if the ad set exposes no targeting or the lookup fails.\n"},"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"},"createdTime":{"type":["string","null"],"format":"date-time","description":"Earliest `platformCreatedAt` (platform ad creation time; falls back to `createdAt`, Zernio's sync time, for ads synced before that field existed) across this ad set's ads. Not the ad set's own creation time on the platform, but a proxy usable for sorting."},"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). When the ad set has more than 100 ads these are the 100 highest-spending over the requested date range, so this array depends on `fromDate`/`toDate`; `adCount` is always the true ad set size. 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"},"createdTime":{"type":["string","null"],"format":"date-time","description":"Earliest `platformCreatedAt` (platform ad creation time; falls back to `createdAt`, Zernio's sync time, for ads synced before that field existed) across every ad in the campaign. Not the platform campaign's own creation time (Meta's `Campaign.created_time` etc. is not synced). A campaign created empty and populated later will show its first ad's time, not the campaign's. Usable for sorting \"most recently created\" without the numeric-campaign-id heuristic. Same source as `AdTreeAdSet.createdTime` and `Ad.platformCreatedAt`; mirrors `AdCampaign.earliestAd`."},"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\nchildren-derived delivery `status`: a campaign can have ads\nalready active (status=active) while the campaign itself is\nstill being reviewed by the platform (reviewStatus=in_review).\nFor Meta, derived from `effective_status` + `issues_info` on\nthe Campaign, plus ad-level PENDING_REVIEW rollup.\n"},"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, LOCAL_SERVICES, 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":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"A single string when every ad set shares one optimization goal; a JSON array of the distinct goals when ad sets differ (never a comma-joined string); array element order is not guaranteed, treat it as an unordered set; the key is absent when no ad set carries a goal. 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":{"anyOf":[{"$ref":"#/components/schemas/AdCampaignBudget"},{"type":"null"}],"description":"Effective budget. Google metadata arrives after the next successful sync."},"campaignBudget":{"anyOf":[{"$ref":"#/components/schemas/AdCampaignBudget"},{"type":"null"}],"description":"Campaign-level budget. Null for ad-set budgets."},"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":{"anyOf":[{"type":"string"},{"type":"array","items":{"type":"string"}}],"description":"A single string when every ad set shares one optimization goal; a JSON array of the distinct goals when ad sets differ (never a comma-joined string); array element order is not guaranteed, treat it as an unordered set; the key is absent when no ad set carries a goal. 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"}}},"AdKeyword":{"type":"object","properties":{"id":{"type":"string"},"accountId":{"type":"string","description":"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"},"qualityScore":{"type":["integer","null"],"description":"Deprecated, use `quality.score`. Google Quality Score, 1-10. Null when unrated."},"quality":{"type":"object","description":"Google Quality Score and the three component ratings behind it\n(`ad_group_criterion.quality_info`). Every field is null until Google has rated the\nkeyword: a keyword with too little traffic is unrated, and negatives are never rated.\nGoogle's own UNKNOWN / UNSPECIFIED buckets are reported as null so \"unrated\" has a\nsingle representation.\n","properties":{"score":{"type":["integer","null"],"description":"Quality Score, 1-10."},"expectedCtr":{"type":["string","null"],"enum":["BELOW_AVERAGE","AVERAGE","ABOVE_AVERAGE",null],"description":"How the click-through rate compares with other ads in the same position (`search_predicted_ctr`)."},"adRelevance":{"type":["string","null"],"enum":["BELOW_AVERAGE","AVERAGE","ABOVE_AVERAGE",null],"description":"How closely the ad matches the intent behind the search (`creative_quality_score`)."},"landingPageExperience":{"type":["string","null"],"enum":["BELOW_AVERAGE","AVERAGE","ABOVE_AVERAGE",null],"description":"How relevant and useful the landing page is to people who click (`post_click_quality_score`)."}}},"syncedAt":{"type":["string","null"],"format":"date-time"},"metrics":{"type":["object","null"],"description":"Trailing 30-day window. Null on rows synced before the metrics columns existed (re-synced on the keyword's next weekly sweep).","properties":{"windowDays":{"type":"integer"},"clicks":{"type":"integer"},"impressions":{"type":"integer"},"cost":{"type":"number","description":"Account currency, not USD-normalized"},"conversions":{"type":"number"},"firstPageCpc":{"type":["number","null"],"description":"Account currency"},"firstPositionCpc":{"type":["number","null"],"description":"Account currency"}}}}},"AdScheduleWindow":{"type":"object","description":"One ad schedule window as Google stores it. Half-open: it is exclusive of the end minute, so 09:00-12:00 and 12:00-17:00 are adjacent, not overlapping.","properties":{"criterionId":{"type":"string","description":"Google campaign criterion id. Changes whenever the window is rewritten, because Google cannot edit a schedule in place."},"resourceName":{"type":"string"},"dayOfWeek":{"type":"string","enum":["MONDAY","TUESDAY","WEDNESDAY","THURSDAY","FRIDAY","SATURDAY","SUNDAY"]},"startHour":{"type":"integer","minimum":0,"maximum":23},"startMinute":{"type":"integer","enum":[0,15,30,45]},"endHour":{"type":"integer","minimum":0,"maximum":24,"description":"24 means midnight at the end of the day."},"endMinute":{"type":"integer","enum":[0,15,30,45]},"bidModifier":{"type":["number","null"],"description":"Bid adjustment for this window, 0.1-10.0. Null when the window runs at the campaign bid."}}},"KeywordEntry":{"description":"A Google Search keyword: a bare string (BROAD match), or an object naming the match type.","oneOf":[{"type":"string","minLength":1,"maxLength":80,"description":"Keyword text; defaults to BROAD match"},{"type":"object","required":["text"],"properties":{"text":{"type":"string","minLength":1,"maxLength":80},"matchType":{"type":"string","enum":["exact","phrase","broad"]}}}]},"ConversionEvent":{"type":"object","description":"A single conversion event to relay to the ad platform. All PII fields\n(email, phone, names) are hashed with SHA-256 server-side using each\nplatform's normalization rules before they leave Zernio. Callers send\nplaintext.\n","required":["eventName","eventTime","eventId","user"],"properties":{"eventName":{"type":"string","description":"Standard event name (Purchase, Lead, CompleteRegistration, AddToCart,\nInitiateCheckout, AddPaymentInfo, Subscribe, StartTrial, ViewContent,\nSearch, Contact, SubmitApplication, Schedule) or a custom string\n(only supported on platforms that accept custom events: Meta and\nOpenAI Ads).\n\nPer-platform behavior:\n- Meta: free-form; standard names match Meta's built-ins.\n- Google: ignored. The conversion action's category determines the type.\n- LinkedIn: ignored. The conversion rule's `type` is locked to the destination.\n- 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.\n","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\nto prevent double-counting. Mapped to event_id on Meta,\ntransactionId on Google, eventId on LinkedIn (LinkedIn deduplicates\nagainst Insight Tag events with the same eventId; the Insight Tag\nevent wins when both arrive).\n","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\nserver-side for Meta and Google. Sent as plaintext to LinkedIn\n(LinkedIn's Conversions API spec requires the raw value).\nMaximum effective list size on LinkedIn is 1.\n"},"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\nfor Conversion Leads CRM events: send it with\n`actionSource: 'crm'` and\n`platformData: { event_source: 'crm', lead_event_source: '<CRM name>' }`.\nForwarded unhashed to Meta's `user_data.lead_id`. Meta only.\n"},"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\nparsing `li_fat_id` from landing-page URLs after the\nadvertiser enables enhanced conversion tracking on the\nLinkedIn Insight Tag. Sent to LinkedIn as the\nLINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID userId. Opaque\ntoken, not hashed.\n"}}}}},"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.\nOn Meta, keys are shallow-merged into `custom_data` only: fields\nZernio already builds (`value`, `currency`, `contents`,\n`num_items`) always win on collision, and `user_data` (hashed\nmatch keys) is never touched. Use first-class fields (e.g.\n`user.leadId`) for anything that must reach `user_data`.\n"}}},"ConversionDestination":{"type":"object","description":"A discoverable conversion destination on an ad platform: a Meta pixel,\nGoogle conversion action, or LinkedIn conversion rule. Returned by\n`listConversionDestinations`, `getConversionDestination`,\n`createConversionDestination`, and `updateConversionDestination`.\n","required":["id","name"],"properties":{"id":{"type":"string","description":"Platform-native identifier. Pass back as `destinationId` on event\nsend and as the path segment on CRUD endpoints.\n"},"name":{"type":"string"},"type":{"type":"string","description":"Present when the platform locks the event type/category to the\ndestination (Google conversion actions, LinkedIn conversion rules).\nAbsent for Meta pixels (which accept any event name per request).\n"},"status":{"type":"string","enum":["active","inactive"],"description":"For LinkedIn, `inactive` means the rule is soft-deleted (`enabled: false`).\n"},"adAccountId":{"type":"string","description":"Set by adapters whose destinations are scoped to a specific ad\naccount (LinkedIn). Pass back on subsequent CRUD calls to\nidentify the parent ad account.\n"}}},"ConversionAction":{"type":"object","description":"A Google Ads conversion action, e.g. a WEBPAGE conversion created via\n`createConversionAction`. Returned by `listConversionActions` and\n`createConversionAction`.\n","required":["id","name","type","status","category","tagSnippets"],"properties":{"id":{"type":"string","description":"Google Ads conversion action id."},"name":{"type":"string"},"type":{"type":"string","description":"Google's ConversionActionType, e.g. WEBPAGE, UPLOAD_CLICKS."},"status":{"type":"string","description":"Google's ConversionActionStatus, e.g. ENABLED, REMOVED, HIDDEN."},"category":{"type":"string","description":"Google's ConversionActionCategory, e.g. DEFAULT, PURCHASE, LEAD."},"tagSnippets":{"type":"array","description":"The code a customer pastes onto their site. Present for types\nGoogle generates a snippet for (e.g. WEBPAGE); empty otherwise.\n","items":{"type":"object","required":["type"],"properties":{"type":{"type":"string","description":"Google's TrackingCodeType, e.g. WEBPAGE."},"pageFormat":{"type":"string","description":"Google's TrackingCodePageFormat, e.g. HTML, AMP."},"globalSiteTag":{"type":"string","description":"The gtag.js snippet to install once per site."},"eventSnippet":{"type":"string","description":"The per-conversion-page snippet that fires the event."}}}}}},"CtwaSingleResponse":{"type":"object","description":"Response returned by `POST /v1/ads/ctwa` when the request used the\nsingle-creative shape (top-level headline / body / imageUrl|video).\n`adType` is the union discriminator.\n","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\nmulti-creative shape (`creatives[]`). N persisted Ad documents share\nthe returned `platformCampaignId` and `platformAdSetId`. `adType` is\nthe union discriminator.\n","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\n`platformCampaignId` and `platformAdSetId`.\n","items":{"type":"object"}},"platformCampaignId":{"type":"string"},"platformAdSetId":{"type":"string"},"message":{"type":"string"}}},"CustomConversion":{"type":"object","properties":{"id":{"type":"string"},"name":{"type":["string","null"]},"rule":{"type":["object","null"],"description":"Meta's rule, parsed back from the string Meta stores."},"customEventType":{"type":["string","null"]},"pixelId":{"type":["string","null"],"description":"Meta's event_source_id, the pixel the rule reads from."},"isArchived":{"type":"boolean"}}},"CustomConversionResult":{"type":"object","properties":{"adAccountId":{"type":"string"},"customConversionId":{"type":"string","description":"Drops straight into promotedObject.customConversionId on POST /v1/ads/create."},"reused":{"type":"boolean","description":"True when an existing conversion matched name + pixelId; the response is then a 200."},"customConversion":{"$ref":"#/components/schemas/CustomConversion"}}},"Blog":{"type":"object","description":"A blog container on the connected platform. All content lives on the platform; Zernio proxies it and stores nothing.","properties":{"id":{"type":"string","description":"Platform-native blog id (numeric string for Shopify)."},"platform":{"type":"string","enum":["shopify"]},"title":{"type":"string"},"handle":{"type":"string","description":"URL slug of the blog."}}},"BlogArticle":{"type":"object","description":"An article inside a blog on the connected platform.","properties":{"id":{"type":"string","description":"Platform-native article id (numeric string for Shopify)."},"blogId":{"type":"string","description":"Platform-native id of the blog the article belongs to."},"platform":{"type":"string","enum":["shopify"]},"title":{"type":"string"},"bodyHtml":{"type":["string","null"],"description":"Article body as HTML."},"handle":{"type":"string","description":"URL slug of the article."},"tags":{"type":"array","items":{"type":"string"}},"author":{"type":["string","null"],"description":"Display name of the article author."},"excerpt":{"type":["string","null"],"description":"Short summary shown in blog listings."},"image":{"type":["object","null"],"description":"Featured image.","properties":{"url":{"type":"string"},"altText":{"type":["string","null"]}}},"isPublished":{"type":"boolean","description":"False while the article is a draft or its publish date is still in the future."},"publishedAt":{"type":["string","null"],"format":"date-time","description":"When the article was (or is scheduled to be) published; null for drafts."},"createdAt":{"type":["string","null"],"format":"date-time"},"updatedAt":{"type":["string","null"],"format":"date-time"}}},"BusinessAgentSettings":{"type":"object","description":"Meta Business Agent settings for one WhatsApp number, as Meta returns them.","required":["agent_id","channel","rollout"],"properties":{"agent_id":{"type":"string"},"channel":{"type":"string","example":"whatsapp"},"rollout":{"type":"object","required":["enabled"],"properties":{"enabled":{"type":"boolean","description":"Whether the agent answers live conversations."}}},"handoff":{"type":["object","null"],"properties":{"enabled":{"type":"boolean","description":"Whether the agent releases the thread after sending its handoff message."},"message":{"type":"string"},"message_selection":{"type":"string","enum":["DEFAULT","AGENT","CUSTOM"]}}},"followup":{"type":["object","null"],"properties":{"enabled":{"type":"boolean"},"followup_interval_in_seconds":{"type":"integer","enum":[0,300,900,1800,3600,7200,28800,86400]},"message":{"type":"string"}}},"ai_audience":{"type":["string","null"],"enum":["EVERYONE","ALLOWLISTED_ONLY",null],"description":"EVERYONE answers all consumers; ALLOWLISTED_ONLY answers only the allowlist and needs no payment method."},"never_say_phrases":{"type":"array","items":{"type":"string"},"description":"Exact phrases the agent must never say."}}},"BusinessAgentStatus":{"type":"object","description":"Where the merchant is in the Meta Business Agent setup for this number.","required":["eligible","termsAccepted","onboarded","enabled","agentId","settings","manualSteps","unverifiedSteps"],"properties":{"eligible":{"type":["boolean","null"],"description":"Whether the number can run the agent; null when the terms are not accepted yet (Meta refuses the check)."},"termsAccepted":{"type":"boolean","description":"False when Meta rejects calls because the merchant has not accepted the terms in WhatsApp Manager."},"onboarded":{"type":"boolean","description":"An agent exists on the number (onboard was called)."},"enabled":{"type":"boolean","description":"The agent answers live conversations."},"agentId":{"type":["string","null"]},"settings":{"oneOf":[{"$ref":"#/components/schemas/BusinessAgentSettings"},{"type":"null"}]},"manualSteps":{"type":"array","description":"Steps Meta keeps outside the API that Zernio can verify are still pending.","items":{"type":"object","required":["step","url","description"],"properties":{"step":{"type":"string","enum":["accept_terms"]},"url":{"type":"string","format":"uri"},"description":{"type":"string"}}}},"unverifiedSteps":{"type":"array","description":"Steps Meta keeps outside the API and exposes no state for, listed once an agent exists. Informational: Zernio cannot tell whether the merchant already did them.","items":{"type":"object","required":["step","url","description"],"properties":{"step":{"type":"string","enum":["attach_payment_method"]},"url":{"type":"string","format":"uri"},"description":{"type":"string"}}}}}},"BusinessAgentAllowlistEntry":{"type":"object","required":["id"],"properties":{"id":{"type":"string"},"consumer_phone_number":{"type":"string","description":"E.164 phone number."}}},"BusinessAgentBusinessInformation":{"type":"object","description":"Free-text facts the agent answers from; PUT replaces the whole object.","properties":{"payment_method":{"type":"string"},"return_policy":{"type":"string"},"purchase_info":{"type":"string"},"delivery_and_shipping":{"type":"string"},"business_description":{"type":"string"},"contact_info":{"type":["object","null"],"properties":{"email":{"type":"string"},"hours_of_operation":{"type":"string"},"address":{"type":"string"}}}}},"BusinessAgentFaqInput":{"type":"object","required":["question","answer"],"properties":{"question":{"type":"string","maxLength":1000,"description":"Phrase it the way a customer would ask it; one topic per entry."},"answer":{"type":"string","maxLength":8000,"description":"Self-contained; the agent retrieves entries independently."},"metadata":{"type":"object","additionalProperties":{"type":"string"}}}},"BusinessAgentFaq":{"allOf":[{"$ref":"#/components/schemas/BusinessAgentFaqInput"},{"type":"object","required":["id"],"properties":{"id":{"type":"string"},"created_at":{"type":"integer","description":"Unix seconds."}}}]},"BusinessAgentWebsiteInput":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri"},"included_sub_domains":{"type":"array","items":{"type":"string"}},"included_url_patterns":{"type":"array","items":{"type":"string"},"description":"Only URLs containing one of these substrings are ingested."},"excluded_sub_domains":{"type":"array","items":{"type":"string"}},"excluded_url_patterns":{"type":"array","items":{"type":"string"}},"single_urls":{"type":"array","items":{"type":"string","format":"uri"},"description":"Crawl only these exact pages instead of the whole site."}}},"BusinessAgentWebsite":{"allOf":[{"$ref":"#/components/schemas/BusinessAgentWebsiteInput"},{"type":"object","required":["id"],"properties":{"id":{"type":"string"},"crawl_status":{"type":"string","description":"not_started, pending, in_progress, completed, completed_no_data or failed (see crawl_error)."},"crawl_error":{"type":"string"},"pages_crawled":{"type":"integer"},"last_crawled_at":{"type":"integer","description":"Unix seconds."},"created_at":{"type":"integer","description":"Unix seconds."}}}]},"BusinessAgentKnowledgeFile":{"type":"object","required":["id","file_name"],"properties":{"id":{"type":"string"},"file_name":{"type":"string"}}},"BusinessAgentSkillInput":{"type":"object","required":["skill"],"properties":{"title":{"type":"string","maxLength":64,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","description":"Lowercase letters, digits and hyphens, e.g. greeting-skill."},"description":{"type":"string","maxLength":1024,"description":"When the agent should apply the skill."},"skill":{"type":"string","maxLength":20000,"description":"The instructions themselves. Avoid two skills that both claim priority for the same situation."}}},"BusinessAgentSkill":{"allOf":[{"$ref":"#/components/schemas/BusinessAgentSkillInput"},{"type":"object","required":["id"],"properties":{"id":{"type":"string"},"channel":{"type":"string"},"created_at":{"type":"integer","description":"Unix seconds."},"status":{"type":"string","enum":["active","pending_review","blocked"],"description":"pending_review right after a write; blocked means Meta content review rejected it and the agent never applies it."}}}]},"BusinessAgentUiSkillInput":{"type":"object","required":["component_type","status","instruction"],"properties":{"title":{"type":"string"},"component_type":{"type":"string","enum":["carousel_quick_reply","carousel_url","cta_url","flow","image","interactive_list","interactive_reply_buttons","location","location_request"]},"status":{"type":"string","enum":["enabled","disabled"]},"instruction":{"type":"string","description":"When to send the component and everything needed to fill its fields."},"flow_id":{"type":"integer","description":"Required for component_type flow, rejected otherwise."}}},"BusinessAgentUiSkill":{"allOf":[{"$ref":"#/components/schemas/BusinessAgentUiSkillInput"},{"type":"object","required":["id"],"properties":{"id":{"type":"string"},"created_at":{"type":"integer","description":"Unix seconds."},"updated_at":{"type":"integer","description":"Unix seconds."}}}]},"BusinessAgentApiKeyConfig":{"type":"object","description":"Where the connector injects the API key on each call.","properties":{"headers":{"$ref":"#/components/schemas/BusinessAgentApiKeyParams"},"query_params":{"$ref":"#/components/schemas/BusinessAgentApiKeyParams"},"body_params":{"$ref":"#/components/schemas/BusinessAgentApiKeyParams"}}},"BusinessAgentApiKeyParams":{"type":"array","items":{"type":"object","required":["field_name","value"],"properties":{"field_name":{"type":"string"},"value":{"type":"string"},"prefix":{"type":"string"}}}},"BusinessAgentOAuthClientCredentials":{"type":"object","required":["token_url","client_id","client_secret"],"properties":{"token_url":{"type":"string","format":"uri"},"scopes_to_request":{"type":"array","items":{"type":"string"}},"token_request_content_type":{"type":"string","example":"application/x-www-form-urlencoded"},"client_id":{"type":"string"},"client_secret":{"type":"string"}}},"BusinessAgentConnectorInput":{"type":"object","required":["name","base_url","auth_type"],"properties":{"name":{"type":"string","description":"Unique per number."},"description":{"type":"string","description":"Tell the agent what the service provides."},"base_url":{"type":"string","format":"uri","description":"Public HTTPS URL reachable from Meta."},"connector_protocol":{"type":"string","example":"HTTP"},"auth_type":{"type":"string","enum":["OAUTH2_CLIENT_CREDENTIALS","API_KEY","NONE"]},"auth_config":{"type":"object","properties":{"oauth2_client_credentials":{"$ref":"#/components/schemas/BusinessAgentOAuthClientCredentials"},"api_key":{"$ref":"#/components/schemas/BusinessAgentApiKeyConfig"}}},"user_auth_injection_config":{"type":"object","required":["location","field_name"],"properties":{"location":{"type":"string","example":"headers"},"field_name":{"type":"string"},"prefix":{"type":"string"}}},"requires_certificate":{"type":"boolean"}}},"BusinessAgentConnector":{"allOf":[{"$ref":"#/components/schemas/BusinessAgentConnectorInput"},{"type":"object","required":["id"],"properties":{"id":{"type":"string"},"mcp_tool_sync":{"type":"object","additionalProperties":true},"mtls_config":{"type":"object","additionalProperties":true},"connection_status":{"type":"object","properties":{"status":{"type":"string","description":"ACTIVE, PENDING_OAUTH, EXPIRED or ERROR."},"error_message":{"type":"string"}}}}}]},"BusinessAgentConnectorToolInput":{"type":"object","required":["name","description","request_definition"],"properties":{"name":{"type":"string"},"description":{"type":"string","description":"When and how the agent should use the operation."},"request_definition":{"type":"object","additionalProperties":true,"description":"Meta request definition: method, path, path_parameters, query_parameters, headers and a typed body schema (content_type, params, required)."},"user_auth_required":{"type":"boolean"},"user_auth_action_config":{"type":"object","additionalProperties":true},"transformation_spec":{"type":"object","additionalProperties":true}}},"BusinessAgentConnectorTool":{"allOf":[{"$ref":"#/components/schemas/BusinessAgentConnectorToolInput"},{"type":"object","required":["id"],"properties":{"id":{"type":"string"}}}]},"BusinessAgentBudget":{"type":"object","required":["unit_type","time_window","max_budget"],"properties":{"budget_id":{"type":"string","description":"Pass it back to edit an existing budget; omit to add one."},"unit_type":{"type":"string","enum":["token","ai_turn"],"description":"Tokens count across the Business Manager, AI turns per conversation."},"time_window":{"type":"string","enum":["one_day","seven_days","fourteen_days","thirty_days"],"description":"Rolling window in the WABA timezone."},"max_budget":{"type":"integer","minimum":1}}},"BusinessAgentTestMessageResponse":{"type":"object","required":["message_id","agent_response","conversation_id"],"properties":{"message_id":{"type":"string"},"agent_response":{"type":"string"},"conversation_id":{"type":"string","description":"Pass it back as conversationId to continue the thread."},"timestamp":{"type":"integer","description":"Unix seconds."},"handoff_reason":{"type":"string"},"no_response_reason":{"type":"string"},"quick_replies":{"type":"array","items":{"type":"string"}},"product_variant_ids":{"type":"array","items":{"type":"string"}}}},"BusinessAgentEventStatus":{"type":"object","required":["status","event_type","created_at","updated_at"],"properties":{"status":{"type":"string","enum":["request_received","processing","sent","failed","skipped","success"]},"event_type":{"type":"string"},"error_message":{"type":"string"},"skipped_reason":{"type":"string"},"created_at":{"type":"string","format":"date-time"},"updated_at":{"type":"string","format":"date-time"}}},"TrackingTag":{"type":"object","description":"A platform measurement tag: the thing you create, install on a\nwebsite, send events to, and target ads against. On Meta this is a\nPixel (`kind: pixel`). The shape is platform-neutral so other platforms\n(Pinterest Tag, LinkedIn Insight Tag, etc.) can be added without\nchanging the contract; platform-specific fields are absent where\na platform has no equivalent. Returned by `listTrackingTags`,\n`createTrackingTag`, `getTrackingTag`, and `updateTrackingTag`.\n","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;\npopulated by `getTrackingTag`, omitted from the list view.\n"},"lastFiredTime":{"type":["integer","null"],"description":"Unix seconds of the last event the tag received, or `null` if it\nnever fired. The practical \"is it installed and working\" signal.\n"},"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\non a personal (non-BM) ad account. Such tags can't be shared with\nother ad accounts.\n"},"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\npublishing successfully. Does NOT wait for the post-level rollup, so\nconsumers building incremental UIs get notified immediately, even\nwhen other platforms on the same post are still processing.\nThe envelope event (`post.published` / `post.partial`) fires\nseparately AFTER all platforms have terminated. A target that later\nfails background reconciliation (e.g. a Facebook video Meta accepted\nbut never actually published) emits `post.platform.failed` for the\nsame target afterward.\n","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\npermanently. Temporary/retryable failures do NOT fire this event,\nonly permanent ones do, so retry loops stay quiet. The envelope event\n(`post.failed` / `post.partial`) fires separately AFTER all\nplatforms have terminated. Can also fire a second time for a target\nthat already emitted `post.platform.published`, if background\nreconciliation later discovers the publish never actually completed.\n","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\npublished through Zernio was later deleted on the platform (e.g.\nthe user deleted the Instagram post natively). Detection is\npoll-driven (~hourly), not real-time, and fires once per platform\ntarget. `platform.deletedAt` carries the detection time. Detection\nis listing-based: a false positive self-heals in Zernio's data when\nthe post reappears, but the event is not retracted. Coverage is\nbounded to the posts the platform listing returns. Detection is a diff\nagainst the posts a prior sync already indexed, so an account for which no\npost has ever been indexed can never emit this event, no matter how the\nsubscription is configured.\n","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\nURL backfilled. TikTok exposes the numeric video id asynchronously\n(often minutes after PUBLISH_COMPLETE), so the terminal events can\ncarry an empty `publishedUrl` for TikTok. This event delivers\n`platform.publishedUrl` and the resolved `platform.platformPostId`\nonce available. At most once per platform target; never fires for\ndrafts or private posts (no public URL exists). Payload shape is\nidentical to `post.platform.published`.\n","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 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 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\ndiscovery + 90-day historical ad backfill) completes. The `sync` block\nreports whether the backfill succeeded and how many ads were synced.\n","tags":["Webhook Events"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookPayloadAccountAdsInitialSyncCompleted"}}}},"responses":{"200":{"description":"Webhook received successfully"}}}},"analytics.synced":{"post":{"operationId":"onAnalyticsSynced","summary":"Analytics synced event","description":"Fired once per connected account each time its analytics sync cycle completes\nsuccessfully. Poll-driven (roughly hourly per account), not real-time, and never\nfired for a skipped or failed cycle.\n\nA trigger, not a transport: the payload carries no metrics and no cursor. On\nreceipt, call `GET /v1/analytics/delta` with your own last `nextCursor` to read\nevery post whose analytics changed, across every account, in one paginated\nstream instead of polling analytics once per account.\n\nThe feed holds back its most recent few seconds of writes, so a read issued the\ninstant this event lands often returns an empty page for that account. Poll again\nwith the same cursor rather than reading an empty page as \"nothing changed\".\n\nHigh volume (roughly one delivery per connected account per hour). Subscribe to\nit on a dedicated webhook endpoint: a subscription's consecutive-failure count is\nshared across all of its events, so an outage while this event is flowing can\nsuppress the low-volume publishing events on the same subscription.\n","tags":["Webhook Events"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookPayloadAnalyticsSynced"}}}},"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.control_changed":{"post":{"operationId":"onConversationControlChanged","summary":"Conversation control changed event","description":"WhatsApp only. Fired when control of a conversation moves between Meta Business Agent\nand your app (Meta's `messaging_handovers`), or when the agent is first seen answering\na thread. While `control.owner` is `ai_agent`, inbound messages arrive on\n`message.received` with `metadata.standby: true` and the agent's replies on\n`message.sent` with `source: meta_business_agent`. Sending any message takes control\nback; release it with `POST /v1/inbox/conversations/{conversationId}/thread-control`.\n","tags":["Webhook Events"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookPayloadConversationControlChanged"}}}},"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\ncontact, in either direction. Works across every DM platform (Instagram, Messenger/Facebook,\nTelegram, WhatsApp, X, Reddit, Bluesky). Naturally deduped: a given conversation\nonly fires this event the very first time it appears.\n","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\nthe event fires at the moment our Telnyx trunk bridges the consumer\nleg to the customer&apos;s forward-to destination; for outbound (BIC)\ncalls it fires immediately after Meta accepts the connect. Branch on\n`call.direction` to distinguish.\n","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\nbreakdown (Meta cost, Telnyx cost, recording surcharge, total).\nCosts are pass-through; no margin is applied.\n","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\nconnect, Telnyx returned an error, etc.). Payload carries the\nupstream error code and message.\n","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`\ninteractive message (or its marketing-template variant). Carries\nthe response (`accept` / `reject`), whether the grant is permanent,\nand the expiration timestamp when it is temporary.\n","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\nCoexistence numbers. Sends that carry platform-specific context deliver it under\n`metadata`, so a quote-reply sent through the API arrives with\n`metadata.quotedMessageId` and mirroring CRMs can thread it without a lookup.\nWhich surfaces actually carry that reference is documented on\n`WebhookPayloadMessageSent.metadata.quotedMessageId`; a quote-reply sent from\nthe WhatsApp Business or Instagram app is not one of them.\n","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\nInstagram, Facebook Messenger, Telegram, and WhatsApp. The payload\nincludes the full editHistory so consumers can show prior versions.\n","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\n(incoming unsend) and WhatsApp in both directions: an outgoing message\nthe business deleted (via the Cloud API, or from the WhatsApp Business\napp on a Coexistence number) and an incoming message the customer\ndeleted. Read `message.direction` to tell the two apart. The payload\nretains the pre-delete text and attachments so API consumers can access\nthe original content for moderation or compliance; the Zernio dashboard\nUI hides it.\n","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.\nSupported on WhatsApp and Facebook Messenger.\n","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\nWhatsApp, Facebook Messenger, and Instagram.\n","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\nfor WhatsApp (other platforms don't expose per-message failure via\nwebhook). The payload error object contains code, title, and\nmessage from the platform.\n","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.\nSupported on WhatsApp, Telegram, Slack, Instagram and Facebook Messenger.\nDistinct from message.received so a reaction (e.g. a thumbs-up) is not\nmistaken for an inbound message.\nThe `reaction.action` field is `added` or `removed`. On WhatsApp and Meta\nremovals the platform does not report which emoji was removed, so\n`reaction.emoji` may be an empty string.\nInstagram and Facebook accounts connected before reactions shipped only\nemit this event after their webhook subscription is refreshed; reconnect\nthe account if reactions never arrive. Requires the Inbox add-on.\n","tags":["Webhook Events"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookPayloadReaction"}}}},"responses":{"200":{"description":"Webhook received successfully"}}}},"referral.received":{"post":{"operationId":"onReferralReceived","summary":"Referral received event","description":"Fired when someone opens an EXISTING Instagram or Messenger thread\nthrough an attributable entry point - an ig.me / m.me link with a\n`ref` parameter, or (Messenger) a returning Click-to-Message ad click -\nwhich Meta delivers as a standalone referral with no message attached.\nA referral that rides an inbound message (first message of a thread,\nicebreaker taps, returning ad clicks on Instagram) arrives on\n`message.received` under `metadata.referral` instead; the two never\nfire for the same click. The first referral captured on a conversation\nis also persisted on it (see `metadata` on `GET\n/v1/inbox/conversations`). Requires the Inbox add-on.\n","tags":["Webhook Events"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookPayloadReferral"}}}},"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. Delivered for\nInstagram, Facebook, Threads, YouTube, LinkedIn, Bluesky, Reddit and\nTikTok. X/Twitter does NOT fire this event. Instagram, Facebook and\nTikTok arrive in real time from the platform's own webhook; the rest are\npoll-driven, so delivery is not instant. TikTok needs an account\nconnected through the TikTok for Business app.\n","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\nfor Google Business Profile (real-time via Pub/Sub). Requires the Inbox add-on.\n","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 Google Business Profile reviewer edits their review text or rating,\nor when a reply is posted through POST /v1/inbox/reviews/{reviewId}/reply.\nA reply written directly in Google's own interface does NOT fire this event,\nbecause Google emits no notification for it. Payload shape matches review.new.\nRequires the Inbox add-on.\n","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\n(created outside Zernio, e.g. a Google Business Profile localPost made in\nthe Google UI) for the first time. Poll-driven (~hourly), not real-time.\n`post.source` is always \"external\".\n","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.\nDetected by comparing text/media structure and, where available, the\nplatform's own edit timestamp; a media-URL-only refresh does not fire this.\n","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.\n`post.deletedAt` carries the detection time. Coverage is bounded to the\nmost recent posts the platform listing returns. Detection is a diff against\nthe posts a prior sync already indexed, so an account for which no post has\never been indexed can never emit this event, no matter how the subscription\nis configured.\n","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\nand ingested via the Page `leadgen` webhook. `lead.fields` is the\nquestion-key to answer map; `lead.formId` / `lead.adId` give provenance.\nRequires the Ads add-on.\n","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.\nCurrently emitted only for Meta (`metaads`).\n\nSubscribed to two Meta `ad_account` webhook fields:\n  - `in_process_ad_objects` - the ad object finished processing and exited\n    the `IN_PROCESS` state. `status.raw` carries Meta's `status_name`\n    (e.g. `ACTIVE`, `PAUSED`, `ARCHIVED`, `DELETED`).\n  - `with_issues_ad_objects` - the ad object entered the `WITH_ISSUES`\n    state. `status.raw` is set to `WITH_ISSUES` and the `error` block is\n    populated from Meta's `error_code` / `error_summary` / `error_message`.\n\n`adObject.level` mirrors Meta's `level` and is one of `CAMPAIGN`,\n`AD_SET`, or `AD`. Creative-level events are not forwarded.\n\nBranch on `status.raw` to handle each transition; use `error.code` (when\npresent) as the stable discriminator, since `error.summary` and `error.message`\nare localized to the ad-account owner's Meta locale.\n\nThe `error` block is optional. It's present on most `WITH_ISSUES`\nevents but can be absent (Meta does not always include diagnostics),\nand is never present on any other status. Always null-check `error`\nbefore reading `error.code`.\n\n**Fan-out:** matching is keyed on `adObject.platformAdAccountId`. When\nmultiple connected Zernio `metaads` accounts are linked to the same Meta\nad account, each receives its own delivery.\n","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\nattached to a connected WABA. Forwarded from Meta's\n`message_template_status_update` webhook field on the WhatsApp\nBusiness Account. Consumers branch on `template.status` (APPROVED,\nREJECTED, PENDING, PAUSED, DISABLED, IN_APPEAL, PENDING_DELETION).\nMeta does not include the previous status or the template's category\nin this event.\n","tags":["Webhook Events"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookPayloadWhatsAppTemplateStatusUpdated"}}}},"responses":{"200":{"description":"Webhook received successfully"}}}},"whatsapp.template.category_updated":{"post":{"operationId":"onWhatsAppTemplateCategoryUpdated","summary":"WhatsApp template category updated event","description":"Fired when Meta reclassifies a WhatsApp Business template's category\nafter approval. Forwarded from Meta's `template_category_update`\nwebhook field on the WhatsApp Business Account. Category drives\nMeta's per-conversation tariff and whether the template is subject\nto the recipient's marketing opt-out. `template.changeType` is\n`scheduled` (24h advance notice) or `applied`; `template.category`\nis always the category right now.\n","tags":["Webhook Events"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookPayloadWhatsAppTemplateCategoryUpdated"}}}},"responses":{"200":{"description":"Webhook received successfully"}}}},"whatsapp.account.name_status_updated":{"post":{"operationId":"onWhatsAppAccountNameStatusUpdated","summary":"WhatsApp display-name review outcome event","description":"Fired when Meta finishes reviewing a WhatsApp Business display-name\nchange. Forwarded from Meta's `phone_number_name_update` webhook\nfield on the WhatsApp Business Account. Fires only on a review\noutcome (`name.status` APPROVED, DECLINED, or PENDING_REVIEW); a name\napplied without review reports `name_status:\nAVAILABLE_WITHOUT_REVIEW` on the phone node instead and produces no\nevent here. `decision` REJECTED maps to DECLINED and DEFERRED maps to\nPENDING_REVIEW, matching the `name_status` vocabulary returned by\n`GET /v1/whatsapp/number-info`. Delivery is at-least-once; dedupe on\n`(account.accountId, name.status, name.requestedName)`.\n","tags":["Webhook Events"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookPayloadWhatsAppAccountNameStatusUpdated"}}}},"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\nEmbedded Signup; not available for EU/UK/JP businesses) detects a\nlead or purchase in a Click-to-WhatsApp conversation. Branch on\n`eventName` (`LeadSubmitted` | `Purchase`). Carries the `ctwa_clid`.\nMeta omits that clid on a minority of referrals on any number\n(coexistence or not, most often WhatsApp Status placements); when it\ndoes, this event can supply it and Zernio writes it back onto the\nconversation, so POST /v1/whatsapp/conversions becomes usable for the\nthread.\n","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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."},"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\nthe synchronous (Tier 1/2) path and the asynchronous regulated (Tier\n3/4) path land here. Lets integrators react without polling\nGET /v1/phone-numbers.\n","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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."},"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\nreview. The number is never billed. `reason` carries the reviewer's\nrejection reason when available.\n","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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."},"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\nregulated number order. The number stays pending (nothing was rejected);\nthe customer can provide the missing information from the dashboard, or\nvia the remediation endpoint. `reason` carries the regulator's request\nverbatim when available. `requirements` lists every requirement on the\norder with the reviewer's current verdict; the `declined` ones are what to\nfix, keyed by the same `requirementId` the remediation endpoint uses.\nVerdicts only change when a reviewer acts, so they describe the review at\n`reviewedAt`, the time of the reviewer's last comment.\n","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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."},"reason":{"type":"string"},"requirements":{"type":"array","description":"Every requirement on the order with the reviewer's current verdict. Omitted when the order's requirements could not be read.","items":{"type":"object","properties":{"requirementId":{"type":"string","description":"Same id as fields[].requirementId on the remediation endpoint."},"label":{"type":"string"},"status":{"type":"string","enum":["approved","pending","declined"]}}}},"reviewedAt":{"type":"string","format":"date-time","description":"When the reviewer last commented on the order. Omitted when there is no reviewer comment."},"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\nstep (e.g. Onfido). `verificationUrl` is the link to forward to the\nnumber's end user; the order completes once they pass.\n","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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."},"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\nnumber stops working until the issue is resolved, after which a\n`whatsapp.number.reactivated` event is sent. `reason` carries the cause\n(e.g. `payment_failed`, `subscription_ended`).\n","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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."},"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\nrecovered) and is usable again.\n","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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."},"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\nbilling cleanup, or an admin). Terminal. `reason` carries the cause\n(e.g. `user_requested`, `cleanup_suspended`).\n","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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."},"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\n(POST /v1/phone-numbers/kyc/share). The number enters review\n(pending_regulatory) under your account; `whatsapp.number.activated` or\n`whatsapp.number.declined` follows once the provider rules on it.\n","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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."},"number":{"type":"object","properties":{"id":{"type":"string"},"phoneNumber":{"type":"string"},"country":{"type":"string"},"profileId":{"type":"string"}}}}}}}},"responses":{"200":{"description":"Webhook received successfully"}}}},"phone_number.stock_available":{"post":{"operationId":"onPhoneNumberStockAvailable","summary":"Phone-number stock available event","description":"Fired by the stock sweep (every 6h) the first time a country you watch\nvia POST /v1/phone-numbers/stock-watches has deliverable numbers again.\nThe watch is consumed, so the event fires once per watch; the stock\ncounts are a snapshot and numbers are sold first come, first served.\nBuy with POST /v1/phone-numbers/purchase.\n","tags":["Webhook Events"],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/WebhookPayloadPhoneNumberStockAvailable"}}}},"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\nthe correct code to POST /v1/verify/verifications/{verificationId}/check).\n","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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."},"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\nof wrong code attempts was reached).\n","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","description":"UTC time at which Zernio generated this event (set once when the event payload is built, before delivery is queued). Retries and redeliveries keep the original value, so it reflects the event, not the delivery attempt."},"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":{"/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.\n\nX uses weighted counting (URLs = 23 chars via t.co, emojis = 2 chars). All other platforms use plain character length.\n\nReturns counts and limits for all 15 supported platform variants.\n\nX returns two rows and this endpoint cannot tell you which one applies to you: it takes only `text`, so it never resolves an account. `twitter` (280) is the free tier limit. `twitterPremium` (25000) applies only when the target X account has a paid X subscription, and publishing enforces 280 instead for any post carrying a poll (this endpoint has no poll input, so the `twitterPremium` row always shows 25000). A free account trusting the `twitterPremium` row can pass validation here and still fail at publish time, where the account's real limit is enforced.\n\nTo validate against the per-account limit, use `POST /v1/tools/validate/post` instead: it accepts an `accountId` per platform entry, resolves X Premium status, and checks the text against the limit publishing enforces, including the poll cap. A missing, foreign, or invalid `accountId` falls back to the conservative 280.\n","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.\n\nAccepts 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.\n\nReturns errors for failures and warnings for near-limit content (>90% of character limit).\n","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.\n\nPerforms a HEAD request (with GET fallback) to detect content type and size. Rejects private/localhost URLs for SSRF protection.\n\nPlatform limits are sourced from each platform's actual upload constraints.\n","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).\n\nWhen 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.\n","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 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"}}}},"/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.\nAccepts both Zernio Post IDs and External Post IDs (auto-resolved). fromDate defaults to 90 days ago if omitted, max range 366 days.\nSingle post lookups may return 202 (sync pending) or 424 (all platforms failed). For follower stats, use /v1/accounts/follower-stats.\n\nLinkedIn 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.\n","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 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","ig_reels_avg_watch_time","ig_reels_video_view_total_time","reposts","reels_skip_rate","completion_rate","profile_views"],"default":"date"},"description":"Sort by date, engagement, or a specific metric. Platform-specific metrics (follows, reposts, reels_skip_rate, ig_reels_*, completion_rate, profile_views) sort a null value as 0."},{"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\nscheduled via Zernio will have isExternal: true in this response.\nUse platformPostUrl to correlate with your original Zernio Post IDs.\n","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/delta":{"get":{"x-resource-group":"analytics","operationId":"getAnalyticsDelta","tags":["Analytics"],"summary":"Analytics changed since a cursor","description":"Cursor feed of the analytics snapshots that CHANGED, across every account you can\nread, in one paginated stream. Built for integrations that would otherwise call\n`GET /v1/analytics` once per connected account. Each page carries changes from\nmany accounts at once, so your call count scales with how much actually changed\nrather than with how many accounts you have. Measured against a fleet of roughly\n1,600 connected accounts: about 1,599 per-account analytics calls an hour became\nabout 205 delta calls an hour, a 7.8x reduction.\n\n**Bootstrap once, then stay in sync.** Take the cursor FIRST: call this endpoint\nwith NO `cursor` and it answers with an empty `data` array plus the feed's current\nposition in `nextCursor`. Then load your baseline from `GET /v1/analytics`, the\nhistorical endpoint, because this one is a rolling 7-day change log and cannot\nreplay history. Then resume from the cursor you took before the baseline. Taking\nthe cursor afterwards instead drops every change that lands while the baseline is\nloading: it is in neither the row you already read nor the feed you resume behind\nit. The overlap this order creates is safe, because metrics are absolute values\nrather than increments, so draining it leaves every post on its newest value.\n`nextCursor` is present on every response, empty pages included, so you always\nhave something to advance with.\n\n**Ordering.** Entries come back oldest first, in the order the feed received\nthem. That order is NOT `syncedAt`: `syncedAt` is stamped when an account's sync\ncycle started, and a slow cycle writes its rows after a faster cycle that started\nlater, so `syncedAt` can go backwards between consecutive entries. Do not sort,\nfilter or resume on it. The cursor is the only stable position, and it is opaque:\npass it back verbatim, and do not parse, construct or compare cursors.\n\n**`hasMore: false` does not mean the feed ended.** This stream has no end and\n`nextCursor` is never null. `hasMore: true` means more changes are already\nwaiting, so call again straight away. `hasMore: false` means you are caught up:\nkeep the cursor and poll again on your normal interval.\n\n**The newest changes settle before they are served.** The feed deliberately holds\nback its last few seconds of writes, so that a row can never become visible\nbehind a cursor you have already advanced past. A read issued the instant an\n`analytics.synced` webhook lands will therefore often return an empty page for\nthat account. Do not read an empty page as \"nothing changed\": poll again with the\nSAME cursor you last used rather than advancing.\n\n**Repeats inside one instant.** A sync cycle occasionally records the same post\ntwice at the same feed position. When that happens the feed delivers one of those\nrows, not both. Measured over a day of production traffic, about 1.3% of rows\nfall in such a group and 99.4% of those groups are identical rows, so this is far\nmore often deduplication than loss. Metrics are absolute values rather than\nincrements, so a later entry for the same post supersedes an earlier one.\n\n**Retention is 7 days.** Changes older than that leave the feed. A cursor older\nthan 6 days is rejected with a `400` (a day of margin, because expiry is lazy).\nRecover the same way you bootstrapped: take a fresh cursor from a call to this\nendpoint with no `cursor`, then re-load from `GET /v1/analytics`, then resume\nfrom that cursor. A consumer that polls at least daily never reaches this.\n\nPairs with the `analytics.synced` webhook, so changes can be read on notification\ninstead of on a timer. That event carries no cursor of its own: keep using the\n`nextCursor` this endpoint gave you.\n\nRequires the same analytics access as `GET /v1/analytics`, and shares the\nstricter per-second rate-limit window applied to analytics endpoints.\n","parameters":[{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Opaque cursor from a previous response's `nextCursor`. Omit it to start from\nnow: the response is then an empty page carrying the feed's current position.\nRejected with a `400` when malformed, or when older than the retention window.\n","example":"v1.WyIyMDI2LTA5LTAxIDE3OjEyOjA0IiwiNjVmMWMwYTllMmI1YWYwMDEyYWIzNGNkIl0"},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":50},"description":"Page size. Out-of-range values are a 400, never a silent clamp."},{"name":"platform","in":"query","schema":{"type":"string"},"description":"Filter to a single platform (for example \"youtube\"). Omit for every platform."},{"name":"profileId","in":"query","schema":{"type":"string","default":"all"},"description":"Filter by profile ID (default \"all\"). Must be a valid profile ID or \"all\"."}],"responses":{"200":{"description":"One page of changed analytics","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AnalyticsDeltaResponse"},"examples":{"bootstrap":{"summary":"First call, no cursor: an empty page carrying the feed position","value":{"data":[],"nextCursor":"v1.WyIyMDI2LTA5LTAxIDE3OjA4OjUxIiwiIl0","hasMore":false}},"page":{"summary":"A page of changed snapshots","value":{"data":[{"postId":"65f1c0a9e2b5af0012ab34cd","accountId":"64e1f0a9e2b5af0012ab34cd","profileId":"64e1f0a9e2b5af0012ab34aa","platform":"youtube","platformPostId":"dQw4w9WgXcQ","publishedAt":"2026-08-28T10:00:05Z","syncedAt":"2026-09-01T17:11:38Z","isDeleted":false,"metrics":{"impressions":15420,"reach":12350,"likes":342,"comments":28,"shares":45,"saves":12,"sends":0,"clicks":189,"views":9310,"follows":4,"igReelsAvgWatchTime":0,"igReelsVideoViewTotalTime":0,"reposts":0,"reelsSkipRate":0,"completionRate":0,"profileViews":0}}],"nextCursor":"v1.WyIyMDI2LTA5LTAxIDE3OjEyOjA0IiwiNjVmMWMwYTllMmI1YWYwMDEyYWIzNGNkIl0","hasMore":true}}}}}},"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.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string","example":"Analytics add-on required"},"code":{"type":"string","example":"analytics_addon_required"}}}}}},"403":{"description":"The caller cannot read the requested profile, the API key is scoped to other\nprofiles, or the profile sits outside the plan's profile limit (`code`\n`PROFILE_OVER_LIMIT`).\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"$ref":"#/components/responses/NotFound"},"500":{"description":"Internal server error","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"The delta feed is temporarily unavailable, for example in the minutes between\nan application release and the analytics store catching up with it. Nothing is\nwrong with the request: retry it unchanged, honouring `Retry-After`. The feed\nanswers this rather than an empty page, because an empty page is\nindistinguishable from \"nothing changed\" and would advance you past a window\nyou never received.\n","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\nfrom looping /v1/analytics/youtube/daily-views over every video when you only need\nchannel totals.\n\nResponse shape matches /v1/analytics/instagram/account-insights so the same client\nhandling works. Requires yt-analytics.readonly scope (412 with reauthorizeUrl if\nmissing). Data has a 2-3 day delay (endDate is clamped accordingly). Max 89 days,\ndefaults to last 30 days. Requires the Analytics add-on.\n\nNOT exposed: impressions (Studio thumbnail impressions) and impressionsClickThroughRate.\nYouTube Analytics API v2 does not expose these for any principal type, not channel\nowners, not Partner Program channels, not content owners with CMS access. The only way\nto get them is Studio CSV export. This is a Google-side limitation.\n","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\".\n\nLive YouTube Analytics v2 metrics:\n  - views\n  - estimatedMinutesWatched\n  - averageViewDuration          (ratio - weighted mean computed across days)\n  - subscribersGained\n  - subscribersLost\n\nZernio-synthesized from daily follower snapshots (cross-platform parity):\n  - followers_gained\n  - followers_lost\n"},{"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,\nso the fetch is internally clamped to 3 days ago; any requested range extending\nbeyond that returns zero values for the tail days. The response's dateRange.until\nfield reflects your requested value.\n"},{"name":"metricType","in":"query","schema":{"type":"string","enum":["time_series","total_value"],"default":"total_value"},"description":"\"total_value\" (default) returns aggregated totals.\n\"time_series\" returns per-day values in the \"values\" array.\n"}],"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"}}}},"502":{"$ref":"#/components/responses/UpstreamFailure"},"503":{"$ref":"#/components/responses/TemporarilyUnavailable"}}}},"/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\n/v1/accounts/{id}/linkedin-aggregate-analytics (which handles personal accounts only).\nBacked by LinkedIn's organizationalEntityShareStatistics,\norganizationalEntityFollowerStatistics, and organizationPageStatistics endpoints.\n\nResponse shape matches /v1/analytics/instagram/account-insights. Max 89 days,\ndefaults to last 30 days. Requires the Analytics add-on.\n\nScope requirements: r_organization_social, r_organization_followers, and\nr_organization_admin must all be present on the account. Accounts connected before\nthese scopes were included in the OAuth flow will return 412 with a reauth hint.\n\nEnforced by this endpoint:\n  - Page-view metrics accept only metricType=total_value (LinkedIn omits per-day\n    segmentation even when the API is called with DAY granularity, so a time-series\n    response would be meaningless).\n  - Date range capped at 89 days.\n\nLinkedIn-side platform limits (not re-enforced here, but worth knowing for larger\nranges in a future release):\n  - Follower stats: rolling 12-month window, end must be no later than 2 days ago.\n  - Share stats: rolling 12-month window.\n","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\n\"impressions,clicks,engagement_rate,organic_followers_gained,followers_gained,followers_lost\".\n\nShare statistics (support both total_value and time_series):\n  - impressions\n  - unique_impressions\n  - clicks\n  - likes\n  - comments\n  - shares\n  - engagement_rate       (0..1, LinkedIn-computed)\n\nFollower-gain statistics (support total_value and time_series):\n  - organic_followers_gained   (per-day organic gains for time_series; sum of organic gains over the range for total_value)\n  - paid_followers_gained      (per-day paid gains for time_series; sum of paid gains over the range for total_value)\n\nPage-view statistics (total_value ONLY - LinkedIn platform limit):\n  - page_views_total\n  - page_views_overview\n  - page_views_careers\n  - page_views_jobs\n  - page_views_life\n\nZernio-synthesized from daily follower snapshots:\n  - followers_gained\n  - followers_lost\n"},{"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:\n  - Account is a personal LinkedIn account, not organization (code personal_account_not_supported, use /v1/accounts/{id}/linkedin-aggregate-analytics instead)\n  - Invalid metric name, metricType, or date range\n"},"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\nADMINISTRATOR role on the organization. LinkedIn enforces admin-only\naccess for all three org statistics endpoints. The error envelope is\ntype platform_error, and the raw LinkedIn error is echoed in the\nplatformError field.\n"},"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\ntime series joined from Zernio's daily snapshotter (AccountStats).\n\nResponse shape matches /v1/analytics/instagram/account-insights. Max 89 days,\ndefaults to last 30 days. Requires the Analytics add-on and the user.info.stats\nscope on the account (412 if missing).\n\nScope intentionally narrow. TikTok's public API exposes only the four counter\nmetrics below. The deep metrics that live in TikTok Studio are NOT available on any\npublic TikTok API, even for Business accounts:\n  - profile_views\n  - account-level impressions / reach\n  - follower inflow / outflow breakdown\n  - video watch time, average watch time, full-watched rate\n  - impression_sources (FYP / Following / Hashtag / Search / Personal profile)\n\nTikTok's Research API doesn't expose those fields either, and is restricted to\nnon-commercial academic use per TikTok's eligibility policy. There is no public\nAPI workaround. Post-level metrics (views, likes, comments, shares per video) are\navailable via /v1/analytics?postId=... from TikTok's /v2/video/query/.\n","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\n\"follower_count,likes_count,video_count,followers_gained,followers_lost\".\n\nLive from /v2/user/info/ (requires user.info.stats scope):\n  - follower_count  (cumulative; time series joined from AccountStats)\n  - following_count (cumulative; time series joined from AccountStats.metadata)\n  - likes_count     (cumulative; time series joined from AccountStats.metadata)\n  - video_count     (cumulative; time series joined from AccountStats.metadata)\n\nZernio-synthesized:\n  - followers_gained  (sum of positive daily follower deltas)\n  - followers_lost    (sum of absolute negative daily deltas)\n"},{"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.\n\"time_series\" returns daily values joined from AccountStats snapshots.\n"}],"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.\nRequires yt-analytics.readonly scope (re-authorization may be needed). YouTube finalizes analytics\nwith a ~3-day delay; by default only finalized days are returned, and an explicit endDate can reach\ninto the delay window (see the endDate parameter). Max 90 days, defaults to last 30 days.\n","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\n(YouTube finalizes analytics with a ~3-day delay). An explicit endDate is honored\nup to today: days inside the delay window are provisional and may still be revised\nby YouTube (see provisionalSince in the response), and days YouTube has not\nprocessed yet are omitted from dailyViews.\n"}],"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\nduration for rendering the curve on a time axis. The curve has up to 100 points\n(elapsedVideoTimeRatio 0.01-1.0) aggregated over the whole date range; YouTube does\nnot support per-day retention breakdowns.\n\naudienceWatchRatio is the absolute share of viewers watching at that point in the\nvideo and can exceed 1 (rewinds and looping, common on Shorts). relativeRetentionPerformance\ncompares against videos of similar length (0 = worst, 0.5 = median, 1 = best).\nYouTube returns an empty curve for videos with very few views or before analytics\nprocessing completes (2-3 day delay).\n\nRequires yt-analytics.readonly scope (re-authorization may be needed).\n","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\n(YouTube finalizes analytics with a ~3-day delay). An explicit endDate is honored\nup to today: days inside the delay window are provisional and may still be revised\nby YouTube (see provisionalSince in the response).\n"}],"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,\nfollower counts). Response shape matches /v1/analytics/instagram/account-insights so the\nsame client handling works across platforms.\n\nMetric names track the current (post-November 2025) Meta Graph API. The legacy\npage_impressions / page_fans / page_fan_adds / page_fan_removes metrics were deprecated\nby Meta on November 15, 2025 and are NOT accepted by this endpoint. Use the replacements\nbelow. Because Meta did not provide direct adds/removes replacements, Zernio synthesizes\nfollowers_gained / followers_lost from the daily follower snapshotter.\n\nMax 89 days, defaults to last 30 days. Requires the Analytics add-on.\n","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\n\"page_media_view,page_post_engagements,page_follows,followers_gained,followers_lost\".\n\nLive Meta metrics (current names, post-Nov-2025):\n  - page_media_view       (replaces deprecated page_impressions)\n  - page_views_total\n  - page_post_engagements\n  - page_video_views\n  - page_video_view_time\n  - page_follows          (replaces deprecated page_fans)\n\nZernio-synthesized from daily follower snapshots (filling the Nov-2025 gap\nleft by the page_fan_adds / page_fan_removes deprecation):\n  - followers_gained\n  - followers_lost\n\nMonetization (opt-in, not in the defaults):\n  - content_monetization_earnings\n  - monetization_approximate_earnings\n\nEach monetization metric is fetched with its own separate Graph call, so requesting both\nadds two calls. Values are approximate and Meta restates them after the fact.\n\ncontent_monetization_earnings returns an object per day and always carries unit\n\"micro_amount\" plus an ISO 4217 \"currency\". monetization_approximate_earnings returns a bare\nnumber per day, so its unit is always \"unspecified\" and its \"currency\" is always null. The two\nare on different scales and are not comparable to each other. Both keep their daily \"values\"\non every metricType and are never rescaled by Zernio.\n\nEarnings here are Page-level daily buckets and \"total\" is their sum. Meta does not\ndocument whether a bucket carries that day's earnings or a running total, and every\nPage measured so far earned exactly 0, so reconcile \"total\" against the Page's own Meta\nexport before relying on it; the daily \"values\" are always returned for that purpose.\nPer-post lifetime earnings are served by GET /v1/analytics/facebook/post-earnings.\n\nA Page that is not enrolled in monetization, or that earned nothing, returns normal daily\nbuckets of 0 in \"metrics\": Meta does not distinguish the two, so a 0 total here does NOT mean\nthe Page is enrolled. \"unavailableMetrics\" covers the narrower case where Meta returned no\nbucket for the metric at all (\"no_data\") or rejected the request outright, and the metric is\nthen omitted from \"metrics\" rather than reported as 0.\n"},{"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.\n\"time_series\" returns daily values in the \"values\" array.\n"}],"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:\n  - Requested a deprecated metric (page_impressions, page_fans, page_fan_adds, page_fan_removes) - use current names instead\n  - Account has no Page selected (metadata.pageAccessToken missing)\n  - Invalid accountId / metrics / metricType / date range\n  - Account is not a Facebook account\n"},"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\nrequest. Requires the Analytics add-on.\n\nEarnings are CUMULATIVE since the post was published, not earnings within a date range, so\nthis endpoint takes no since/until and the totals must not be summed across dates or across\nposts. Page-level daily earnings live on /v1/analytics/facebook/page-insights.\n\nA post on a Page that is not enrolled in monetization, or that earned nothing, returns\n\"total\": 0 rather than an error: Meta does not distinguish the two. A metric Meta returned no\nbucket for at all is reported in \"unavailableMetrics\" and omitted from \"metrics\", never as a 0.\n\nAmounts are the platform's raw numbers in the stated \"unit\" and are never rescaled by Zernio.\nBreakdown dimensions are not exposed and a \"breakdown\" param is rejected with 400. So are\n\"since\", \"until\", \"period\", and \"metricType\": scoping this endpoint to a window is not\npossible, and silently returning the lifetime total for one would let a caller sum a year of\nweekly requests into a figure ~52x the post's real earnings.\n","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\n/v1/analytics: \"{pageId}_{postId}\", or the bare video ID for Reels.\n"},{"name":"metrics","in":"query","schema":{"type":"string"},"description":"Comma-separated list of monetization metrics. Defaults to both:\n  - content_monetization_earnings\n  - monetization_approximate_earnings\n\ncontent_monetization_earnings always carries unit \"micro_amount\" plus an ISO 4217\n\"currency\". monetization_approximate_earnings is always a bare number, so its unit is\n\"unspecified\" and its \"currency\" is null. The two are on different scales and are not\ncomparable to each other. Any other metric name is rejected with 400.\n"}],"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:\n  - Invalid accountId format, or a metric name that is not a monetization metric\n  - A \"breakdown\" param was supplied (breakdown dimensions are not exposed)\n  - A \"since\", \"until\", \"period\", or \"metricType\" param was supplied (this endpoint returns a lifetime total and takes no date range)\n  - Account has no Page access token (metadata.pageAccessToken missing)\n  - Account is not a Facebook account\n"},"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.\nThese metrics reflect the entire account's performance across all content surfaces (feed, stories, explore, profile),\nand are fundamentally different from post-level metrics. Data may be delayed up to 48 hours.\nMax 90 days, defaults to last 30 days. Requires the Analytics add-on.\n","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\".\nValid metrics: reach, views, accounts_engaged, total_interactions, comments, likes, saves, shares,\nreplies, reposts, follows_and_unfollows, profile_links_taps.\nNote: only \"reach\" supports metricType=time_series. All other metrics (including\nfollows_and_unfollows) are total_value only. This is an Instagram Graph API limitation,\nnot a Zernio limitation - the IG API does not return time-series data for these metrics.\nFor a daily running follower count, use /v1/analytics/instagram/follower-history instead.\n"},{"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.\n\"time_series\" returns daily values but only works with the \"reach\" metric.\n"},{"name":"breakdown","in":"query","schema":{"type":"string"},"description":"Breakdown dimension (only valid with metricType=total_value).\nValid values depend on the metric: media_product_type, follow_type, follower_type, contact_button_type.\n"}],"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\ncross-platform daily snapshotter. Exists because Meta removed follower_count from\nthe /insights endpoint in Graph API v22+ and never exposed a historical daily series\nvia any public API.\n\nResponse envelope matches /v1/analytics/instagram/account-insights so the same client\nhandling works. Max 89 days, defaults to last 30 days. Requires the Analytics add-on.\n","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\".\n  - follower_count   : per-day raw follower count\n  - followers_gained : sum of positive daily deltas\n  - followers_lost   : sum of absolute negative daily deltas\n"},{"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).\n\"time_series\" returns per-day values in the \"values\" array.\n"}],"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.\nRequires at least 100 followers. Returns top 45 entries per dimension.\nData may be delayed up to 48 hours. Requires the Analytics add-on.\n","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.\n"},{"name":"breakdown","in":"query","schema":{"type":"string"},"description":"Comma-separated list of demographic dimensions: age, city, country, gender.\nDefaults to all four if omitted.\n"},{"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\".\n"}],"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.\nPass videoId to get the audience profile of a single video instead of the whole channel.\nAge and gender values are viewer percentages (0-100). Country values are view counts.\nData is based on signed-in viewers only, with a 2-3 day delay. YouTube suppresses demographics\nfor videos with too few signed-in views, so low-traffic videos can return empty breakdowns.\nRequires the Analytics add-on.\n","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\n(must belong to the connected channel; otherwise 404 video_not_found).\n"},{"name":"breakdown","in":"query","schema":{"type":"string"},"description":"Comma-separated list of demographic dimensions: age, gender, country.\nDefaults to all three if omitted.\n"},{"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\npublish date (lifetime) when videoId is provided.\n"},{"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\n(YouTube finalizes analytics with a ~3-day delay). An explicit endDate is honored\nup to today: days inside the delay window are provisional and may still be revised\nby YouTube (see provisionalSince in the response).\n"}],"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},{"dimension":"female","value":25}],"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"}}}}}}}},"502":{"$ref":"#/components/responses/UpstreamFailure"},"503":{"$ref":"#/components/responses/TemporarilyUnavailable"}}}},"/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.\nEach day includes post count, platform distribution, and summed metrics (impressions, reach, likes, comments, shares, saves, clicks, views).\nDefaults to the last 180 days. Requires the Analytics add-on.\n","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 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.\n\"publish\" (default) sums each post's lifetime total on its publish date.\n\"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.\n"}],"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.\nGroups all published posts by day of week and hour (UTC), calculating average engagement per slot.\nUse this to auto-schedule posts at optimal times. Requires the Analytics add-on.\n","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 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.\nEach bucket shows what percentage of the post's total engagement had been reached by that time window.\nUseful for understanding content lifespan (e.g. \"posts reach 78% of total engagement within 24 hours\").\nRequires the Analytics add-on.\n","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 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.\nHelps find the optimal posting cadence for each platform. Each row represents a specific (platform, posts_per_week) combination\nwith the average engagement rate observed across all weeks matching that frequency.\nRequires the Analytics add-on.\n","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 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,\nand other metrics evolved day-by-day since publishing. Each row represents one day of data per platform.\nFor multi-platform Zernio posts, returns separate rows for each platform. Requires the Analytics add-on.\n","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.\n"},{"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 Google Business Profile performance metrics","description":"Returns daily performance metrics for a Google Business Profile location.\nMetrics include impressions (Maps/Search, desktop/mobile), website clicks,\ncall clicks, direction requests, conversations, bookings, and food orders.\nData may be delayed 2-3 days. Max 18 months of historical data.\nRequires the Analytics add-on.\n","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.\nValid values: BUSINESS_IMPRESSIONS_DESKTOP_MAPS, BUSINESS_IMPRESSIONS_DESKTOP_SEARCH,\nBUSINESS_IMPRESSIONS_MOBILE_MAPS, BUSINESS_IMPRESSIONS_MOBILE_SEARCH,\nBUSINESS_CONVERSATIONS, BUSINESS_DIRECTION_REQUESTS, CALL_CLICKS, WEBSITE_CLICKS,\nBUSINESS_BOOKINGS, BUSINESS_FOOD_ORDERS, BUSINESS_FOOD_MENU_CLICKS\n"},{"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"}}}}}},"502":{"$ref":"#/components/responses/UpstreamFailure"},"503":{"$ref":"#/components/responses/TemporarilyUnavailable"}}}},"/v1/analytics/googlebusiness/search-keywords":{"get":{"x-resource-group":"analytics","operationId":"getGoogleBusinessSearchKeywords","tags":["Analytics"],"summary":"Get Google Business Profile search keywords","description":"Returns search keywords that triggered impressions for a Google Business Profile location.\nData is aggregated monthly. Keywords below a minimum impression threshold set by Google are excluded.\nMax 18 months of historical data. Requires the Analytics add-on.\n","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"}}}}}},"502":{"$ref":"#/components/responses/UpstreamFailure"},"503":{"$ref":"#/components/responses/TemporarilyUnavailable"}}}},"/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\nevents into three projections so the client can render the volume\nchart, KPI strip, and per-platform stacked bar from a single call.\nMax date range is 365 days.\n","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\nsparse: only cells with at least one event are returned; clients\nzero-fill the rest to render the full 7×24 grid. The `dow` field\nfollows ClickHouse's `toDayOfWeek` convention (1 = Monday … 7 =\nSunday). Max date range is 365 days.\n","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\n`metadata.source` field set at ingest time: human / workflow /\nsequence / broadcast / comment_automation / api / contact /\nplatform). Each source row also carries a per-platform sub-split.\nMax date range is 365 days.\n","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\nnext sent message in the same conversation and reports the delta\nas both summary statistics and a fixed-bucket histogram suited\nfor the analytics page's TTR chart.\n\n`sampleSize` reflects only conversations that received AND got a\nreply in the window. Received-but-never-answered conversations\nare excluded. Compare against /v1/analytics/inbox/volume's\n`summary.received` to compute reply rate.\n\nMax date range is 365 days.\n","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 accounts by inbox message volume. Decorates\neach row with display labels from the live SocialAccount record\n(so the UI shows username + displayName, not only an ID). Accounts\nthat no longer map to a SocialAccount surface as \"(disconnected)\"\nso the row stays visible. Max date range is 365 days.\n","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 an em dash 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\ntimestamps. The inbox analog of GET /v1/analytics (posts listing):\nsame filter shape, same pagination, same sort/order semantics.\nUse as the entry point for the per-conversation analytics drawer\nat /v1/analytics/inbox/conversations/{conversationId}.\n\nRows are enriched with the conversation's participant info\n(`participantName`, `participantUsername`, `participantPicture`)\nand last-message preview by joining the Conversation document\nscoped to the caller's team. Max date range is 365 days.\n","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\n/v1/analytics/post-timeline: one conversation, daily totals,\nsource mix.\n\nThe {conversationId} path param accepts EITHER the Mongo `_id` of\nthe Conversation document OR its `platformConversationId` (the\nsame identity used by metadata.conversationId at ingest time).\nOwnership is verified in MongoDB against the caller's team\nbefore the Tinybird query fires.\n\nMax date range is 365 days.\n","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\ncontain accounts from multiple profiles. For API keys scoped to specific\nprofiles, only groups whose accounts all live in allowed profiles are\nreturned.\n","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\ncross-profile groups were supported. New groups omit it.\n"}}}}}},"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 account IDs.\nAccounts can belong to different profiles; the caller must have access to\nevery account's profile. Group names must be unique per user.\n","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.\nGroups are no longer scoped to a single profile.\n","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":{"$ref":"#/components/schemas/MediaContentType"},"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.\nIt may have been disconnected or deleted, or it belongs to a profile\nthe key cannot access. Re-connecting an account issues a NEW account\nID, so an ID stored from before a reconnect will not resolve.\n","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.\nIt may have been disconnected or deleted, or it belongs to a profile\nthe key cannot access. Re-connecting an account issues a NEW account\nID, so an ID stored from before a reconnect will not resolve.\n","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,\naccrued balance + remaining credits this period, spend caps, and\npayment / access status. This is the billing half of the legacy\n`/v1/usage-stats` snapshot. The per-product consumption half is metering\nand lives on `GET /v1/usage`.\n\nAccounts on usage-based billing get a populated `balance`; legacy Stripe\naccounts get `balance: null` plus a deprecated `legacy.limits` block and,\nwhen payment-blocked, `status.openInvoiceUrl` / `status.declineReason`.\n","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 API pricing table","description":"Returns Zernio's canonical X API pricing table. Each X action has its\nown billing product and its own rate, and Zernio passes X API costs through\nat exact rates with zero markup.\n\nThe response is identical for every authenticated user (pricing is universal),\nso it is safe to cache on the client for the duration of a billing period.\n\nTo compute your own per-operation spend, pair this endpoint with\n`GET /v1/usage-stats`, which returns `usage.xApiCallsByOperation`\nkeyed by the same `operation` field you get here.\n","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.01,"operationCount":10},{"tier":"x_api_015","pricePerCallUsd":0.015,"operationCount":3},{"tier":"x_api_200","pricePerCallUsd":0.2,"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.2,"pricePerCallCents":20,"tier":"x_api_200","triggeredBy":[{"method":"publishPost","metering":"always"}]},{"operation":"article_draft","eventType":"x_article_draft","displayName":"X API: Article Draft","pricePerCallUsd":0.01,"pricePerCallCents":1,"tier":"x_api_010","triggeredBy":[{"method":"createArticleDraft","metering":"always"}]},{"operation":"article_publish","eventType":"x_article_publish","displayName":"X API: Article Publish","pricePerCallUsd":0.01,"pricePerCallCents":1,"tier":"x_api_010","triggeredBy":[{"method":"publishArticle","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, and fully backward\ncompatible:\n\n**Without metering params (the default):** the plan / quota / usage\nsnapshot: plan name, billing period, limits, usage counts, access\nstate. Identical to `GET /v1/usage-stats`. Existing integrations keep\nworking unchanged.\n\n**With `range`, `granularity`, `from`, or `to`:** usage METERING:\nbilled spend (USD) by product family (`accounts`, `numbers`, `calls`,\n`sms`, `dlc`, `xApi`, `credits`, `other`) over the window, at\n`day` / `month` / `total` granularity, from the usage-based invoice\nbreakdown (the CHARGE view, which always reconciles with what gets billed).\nAlso served at `GET /v1/usage/daily`. Usage-based accounts only:\nlegacy Stripe accounts get `{ \"supported\": false, \"days\": [] }`.\n\n**Attribution (metering mode):** `groupBy=profile|account` adds an\n`attribution` breakdown of the window's spend per profile or account,\nassembled from your own records and pro-rated against the invoice so\n`sum(groups) + unattributed` equals `totals` exactly. `profileId` /\n`accountId` instead project the whole payload (`days`, `totals`,\n`lineItems`) onto that one group; `peaks`, `callUsage` and `tax` are\nthen `null` (team-level facts). Projected `days` spread the\ngroup's period share over each day (usage is attributed per period,\nnot per day). Profile-scoped API keys and members only see their\nprofiles' groups (`attribution.restricted: true`, with `totals`\nsumming the visible groups). Credits, 10DLC fees and Verify are always\nunattributed. `profileId` / `accountId` on their own do not select\nmetering mode: pair them with `range`.\n\nFor per-domain consumption *volumes* use `GET /v1/usage/calls` and\n`GET /v1/usage/sms`. For the billing statement (balance, credits,\ncaps, payment status) use `GET /v1/billing`.\n","parameters":[{"name":"reconcile","in":"query","required":false,"schema":{"type":"boolean"},"description":"Snapshot mode only. For Stripe subscription users, `true` forces a\nsubscription reconciliation pass even when cached plan data looks\ncomplete.\n"},{"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\nreal billing-period bounds (falling back to a trailing 30 days when\nno invoice exists yet); `7d`…`12mo` are trailing windows; `custom`\nuses `from` / `to`.\n"},{"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),\n`month` (one row per calendar month, dated to the 1st), or `total`\n(no series, read `totals`). Does not affect `totals`.\n"},{"name":"groupBy","in":"query","required":false,"schema":{"type":"string","enum":["profile","account"]},"description":"Metering mode. Adds `attribution`: the window's spend split per profile or per account (keys are ids; resolve names via `GET /v1/profiles` / `GET /v1/accounts`)."},{"name":"profileId","in":"query","required":false,"schema":{"type":"string"},"description":"Metering mode (pair with `range`). Project the payload onto this profile's attributed share. Mutually exclusive with `accountId`, and `groupBy` (if given) must be `profile`; 404 when the profile is not in your team (or outside a scoped key's profiles)."},{"name":"accountId","in":"query","required":false,"schema":{"type":"string"},"description":"Metering mode (pair with `range`). Project the payload onto this account's attributed share. Mutually exclusive with `profileId`, and `groupBy` (if given) must be `account`; 404 when the account is not visible to the caller."}],"responses":{"200":{"description":"Snapshot (no metering params) or billed spend by product over the\nwindow (with metering params).\n","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\nperiod, plan limits, usage counts, and access state. Identical to a\nbare `GET /v1/usage` call (this path is its deprecated alias). For\nbilled spend by product, call `GET /v1/usage` with `range` /\n`granularity` params. The statement view (balance, credits, caps,\npayment status) lives at `GET /v1/billing`.\n\nThe response shape depends on the account's `billingSystem`:\n  * Stripe users: per-period `usage.uploads` / `usage.profiles` counters.\n  * Usage-based billing users: `usage.connectedAccounts`,\n    `usage.xApiCallsByOperation` (per-operation X API call counts;\n    resolve keys via `GET /v1/billing/x-pricing`), plus a `spend`\n    block with `currentPeriodCents`, `xSpendCents`, and\n    `xSpendLimitCents`. The legacy `usage.xApiCalls` 3-tier\n    aggregate is still emitted for back-compat but excludes the\n    $0.200 URL tier and any future tiers, so new clients should\n    consume `xApiCallsByOperation` only.\n","parameters":[{"name":"reconcile","in":"query","required":false,"schema":{"type":"boolean"},"description":"For Stripe subscription users, `true` forces a subscription\nreconciliation pass even when cached plan data looks complete.\nOmit the parameter, or pass `false`, to use the default\nfirst-time-only reconciliation behavior. Invalid boolean values are\nrejected.\n"}],"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":"Usage-based billing user","value":{"billingSystem":"metronome","planName":"Usage-Based","billingPeriod":"monthly","limits":{"uploads":-1,"profiles":-1},"usage":{"connectedAccounts":5,"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,"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\n(WhatsApp Business Calling + regular phone/PSTN): call counts,\nanswered counts, minutes, and cost. Use it for cost visibility or to\nrebill your own customers per number.\n\nCosts come from each call's billing snapshot, so this endpoint always\nagrees with the invoice: `billableUSD` is what Zernio bills;\n`metaUSD` is the WhatsApp per-minute charge Meta bills directly to\nyour WABA (display only, never billed by Zernio).\n\nOptional `groupBy` returns a breakdown by UTC day, by your number, or\nby channel. Defaults to the last 30 days.\n","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\ntotal message counts, with an optional breakdown by UTC day or by\nnumber. Defaults to the last 30 days.\n\nVolumes only, deliberately: SMS cost is carrier-rated asynchronously\nand billed to your invoice, so per-message cost is not available here.\nCalling usage (GET /v1/usage/calls) does include billable cost.\n","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","publishing","published","partial","failed","cancelled"]}},{"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 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 a post, and optionally publish it in the same request. A post published immediately (`publishNow: true`) comes back with `platformPostUrl` in the response.\n\n`content` is optional in four cases:\n\n- media is attached\n- all platforms have `customContent`\n- every platform entry is an X Article (`platformSpecificData.article`)\n- every platform entry is a LinkedIn text-free reshare (`platformSpecificData.reshareUrl` with no text)\n\nSee each platform's schema for media constraints.\n\n## Scheduling\n\nPick one of:\n\n- `scheduledFor`: publish at the scheduled time\n- `publishNow: true`: publish synchronously, inside this request\n- `queuedFromProfile`: publish in the profile's next queue slot\n\nWith none of them and `isDraft` unset, the post is saved as a draft. `platforms` is required unless the post is a draft.\n\nPrecedence: `isDraft: true` wins over `publishNow` and `scheduledFor` (the post is saved, never published), and `publishNow: true` wins over `scheduledFor`. A `scheduledFor` already in the past is not rejected: the post is published synchronously in the same request, exactly like `publishNow`.\n\n## Idempotency\n\nTwo layers of duplicate-protection apply, so safe-to-retry callers (network blips, n8n / Zapier retries, etc.) don't accidentally double-post.\n\n**1. Same-request idempotency (5-minute window).**\nPass 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, and no new post is created.\n\nThe official Zernio SDKs auto-generate a unique `x-request-id` per call. On a generic HTTP client (curl, n8n's HTTP node, Zapier, custom code), either:\n\n- Set a unique `x-request-id` per logical call (recommended, UUIDv4 is fine)\n- Or omit the header, and we'll treat each request as new\n\n**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.\n\n**2. Content-hash dedup (24-hour window).**\nIndependently, 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`. The response carries `error`, `accountId`, `platform`, and `existingPostId` so you can find the original.\n\nTo intentionally re-post identical content within 24h, change something (the caption, the media, the account), because the dedup is keyed on the full content fingerprint.\n\nOrder: same-`x-request-id` retries (200) are checked first; if no idempotency match, the content-hash dedup (409) runs.\n","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.\n"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","description":"Stored on the post for reference/display only. This field is NOT used as the video title when publishing. To set a YouTube video title, use platformSpecificData.title on the youtube platform target (falls back to the first line of content when omitted)."},"content":{"type":"string","description":"Post caption/text. Optional when media is attached, all platforms have customContent, every platform entry is an X Article (platformSpecificData.article), or every platform entry is a LinkedIn text-free reshare (platformSpecificData.reshareUrl with no text). Required for other text-only posts."},"mediaItems":{"type":"array","description":"Media attached to every platform in the request (a platform entry can override it with `customMedia`). Each entry needs a publicly reachable HTTPS `url`; `type` (image, video, gif, document) is inferred from the URL extension when omitted and a `type` that contradicts the extension is rejected with 400. Upload files with `POST /v1/media/presign` first; per-platform size, duration and format limits are listed on each platform schema.","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","description":"When to publish. Required unless `publishNow` is true, `queuedFromProfile` is set, or the post is a draft. An ISO 8601 value with a `Z` or offset (`2026-01-15T10:00:00Z`, `2026-01-15T11:00:00+01:00`) is taken as-is; a value without one (`2026-01-15T10:00:00` or `2026-01-15 10:00`) is read as local time in `timezone`. A value already in the past is published synchronously in the same request. Ignored when `publishNow` is true."},"publishNow":{"type":"boolean","default":false,"description":"Publish to every platform synchronously in this request instead of scheduling; the response then carries each platform result and `platformPostUrl`, with HTTP 207 when some platforms failed. Takes precedence over `scheduledFor`; ignored when `isDraft` is true."},"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."},"dryRun":{"type":"boolean","default":false,"description":"TikTok only. Preview whether each `tiktok` entry in `platforms` could publish right now under the TikTok Direct Post daily limits, without creating, scheduling or publishing anything: no post is persisted and no upload slot is claimed, so it can be repeated freely. The request still goes through auth, the payment gate and body validation, then returns HTTP 200 with `{ dryRun: true, canPublish, tiktok: [...] }` instead of 201. Only `tiktok` entries are evaluated; other platforms in the body are ignored, and a body with no `tiktok` entry is rejected with 400 `invalid_field_value` on `platforms`. An entry with `platformSpecificData.tiktokSettings.draft: true` (Creator Inbox upload) is not subject to the limit and always reports `canPublish: true`. Accounts connected through the TikTok for Business app do not go through these limits at all and also always report `canPublish: true`, so on those accounts a dry run confirms the request is well-formed rather than gating it."},"timezone":{"type":"string","default":"UTC","description":"IANA timezone (`Europe/Madrid`, `America/New_York`) used to interpret a `scheduledFor` (root or per-platform) that carries no `Z` or offset. Has no effect on values that already carry one. An unknown name returns 400 when `scheduledFor` is set."},"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","description":"Stored for reference only. Hashtags are NOT automatically appended to the caption when publishing. Include hashtags directly in the content field (platforms like Instagram only support hashtags as caption text). For YouTube keywords, use the tags field instead.","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,"description":"Stored on the post and echoed back on reads. Publishing does not branch on it: every entry in `platforms` is published regardless, so treat it as a label for your own tooling."},"metadata":{"type":"object","additionalProperties":true,"description":"Free-form key/value pairs of your own, stored on the post and returned on reads and in webhook payloads. Zernio also writes the bookkeeping keys `usageCounted`, `usageRefunded` and `hidden` into this object; do not set them, and they are stripped from webhook payloads."},"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.\nOnly used when queuedFromProfile is also provided.\nIf omitted, uses the profile's default queue.\n"}}},"examples":{"twitterArticle":{"summary":"Publish a long-form X Article","description":"Creates an X Article draft and publishes it. This normally makes two\nbillable X API calls at $0.010 each ($0.020 total). The account must\nbe eligible for X Articles (X Premium+). Article media is supplied as\npublic URLs; Zernio uploads it to X and builds the native media shape.\n","value":{"platforms":[{"platform":"twitter","accountId":"64e1f0a9e2b5af0012ab34cd","platformSpecificData":{"article":{"title":"Building a better publishing workflow","mode":"publish","cover":{"url":"https://cdn.example.com/article-cover.jpg"},"content_state":{"blocks":[{"type":"header-one","text":"A reliable publishing workflow"},{"type":"unstyled","text":"Long-form publishing should be observable. Read the implementation notes.","inline_style_ranges":[{"offset":0,"length":9,"style":"bold"}],"entity_ranges":[{"key":0,"offset":52,"length":20}]},{"type":"atomic","text":" ","entity_ranges":[{"key":1,"offset":0,"length":1}]}],"entities":[{"key":"0","value":{"type":"link","mutability":"mutable","data":{"url":"https://example.com/implementation-notes"}}},{"key":"1","value":{"type":"image","mutability":"immutable","data":{"url":"https://cdn.example.com/workflow.png","caption":"The publishing stages"}}}]}}}}],"publishNow":true}},"twitterArticleDraft":{"summary":"Save an X Article as a platform draft","description":"Creates only the X Article draft ($0.010) and does not publish it publicly.","value":{"platforms":[{"platform":"twitter","accountId":"64e1f0a9e2b5af0012ab34cd","platformSpecificData":{"article":{"title":"Draft product update","mode":"draft","content_state":{"blocks":[{"type":"unstyled","text":"This Article is ready for final review."}],"entities":[]}}}}],"publishNow":true}},"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-10 card carousel where each image has its own click-through link\nand optional headline. mediaItems and carouselCards must have the same\nlength, in the same order. Images only (no video cards).\n","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.\nText-only: mediaItems and carouselCards are rejected, and contentType\nmust not be story or reel.\n","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\nnotification and completes the post via TikTok's editing flow. Uses draft: true\nwhich maps to TikTok API post_mode MEDIA_UPLOAD. Note: publish_type is not a\nsupported field; use draft instead.\n","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\npublished immediately via TikTok API post_mode DIRECT_POST.\n","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\nTikTok endpoint (/v2/post/publish/inbox/video/init/) that only accepts source_info,\nso post_info fields (privacyLevel, allowComment, etc.) are set by the creator\nduring TikTok's editing flow.\n","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 (X + 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":{"200":{"description":"A dryRun preview (TikTok only): nothing was created. Deliberately carries no numeric\ncap detail, only a per-account go/no-go and a reason.\n\nThe schema is a union only so generated clients can type both success shapes of this\noperation: a 200 is always the dry-run verdict, and a created post is always a 201.\n","content":{"application/json":{"schema":{"oneOf":[{"$ref":"#/components/schemas/TikTokDryRunVerdict"},{"$ref":"#/components/schemas/PostCreateResponse"}]}}}},"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"}}}}}},"207":{"description":"The post was created, but the inline publish (`publishNow: true`, or a `scheduledFor` that is already due) did not fully succeed.\n\n**207 is a 2xx status.** `fetch(...).ok` is `true` and axios' default `validateStatus` resolves, so a client that only checks for success will read this as a published post. Branch on the status code explicitly.\n\nTell the outcomes apart with `post.status`:\n- `partial` - at least one platform published and at least one failed. Per-platform detail is in `platformResults` and in `post.platforms[]`.\n- `failed` - no platform published. Terminal; nothing will be retried. Read `platforms[].errorMessage`, `platforms[].errorCategory` and `platforms[].errorSource` to decide whether the caller, the platform or Zernio must act.\n- `scheduled` - every platform hit a transient error and was reset to `pending`. Zernio retries automatically. This is **not** a failure and must not be surfaced to an end user as one.\n\nA publish attempt that aborted before it started (for example the post was already being processed) reports none of the three: `post.status` is whatever it already was and `platformResults` is absent. Read `error` and `post.platforms[]`, which is always present.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostPublishIncompleteResponse"},"examples":{"allPlatformsFailed":{"summary":"Every platform failed (post.status is failed, terminal)","value":{"post":{"_id":"6a9e43f7fd28fe46ce033d98","content":"We just launched!","status":"failed","platforms":[{"platform":"facebook","status":"failed","errorMessage":"Facebook needs you to confirm your identity before you can publish as this Page. Open the Facebook mobile app, go to this Page, and complete the identity confirmation, then try again.","errorCategory":"platform_rejected","errorSource":"platform"}]},"message":"Post created but publishing failed","error":"All platforms failed","platformResults":[{"platform":"facebook","status":"failed","error":"Facebook needs you to confirm your identity before you can publish as this Page. Open the Facebook mobile app, go to this Page, and complete the identity confirmation, then try again."}]}},"temporaryErrorsWillRetry":{"summary":"Transient errors (post.status is scheduled, Zernio retries)","value":{"post":{"_id":"6a9e43f7fd28fe46ce033d99","content":"We just launched!","status":"scheduled","platforms":[{"platform":"instagram","status":"pending"}]},"message":"Post created but publishing failed","error":"Publishing encountered temporary errors. The post will be retried automatically.","platformResults":[{"platform":"instagram","status":"pending","error":null}]}}}}}},"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:\n- `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.\n- `ACCOUNT_NOT_ENABLED_FOR_POSTING`: a target account was connected for ads only (`enabled: false`) and cannot be posted to. Connect it as a posting account (it then counts as a connected account), then refresh account IDs from `GET /v1/accounts`.\n- `PROFILE_OVER_LIMIT`: a target account belongs to a profile beyond the plan's profile limit.\n- No `code`: a target `accountId` does not belong to the authenticated user (or is outside the API key's profile scope).\n","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","ACCOUNT_NOT_ENABLED_FOR_POSTING","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).\n\nBody fields:\n- `error`: human-readable message\n- `details.accountId`: the account that already has this content\n- `details.platform`: the platform that already has this content\n- `details.existingPostId`: Zernio `_id` of the original post\n\nTo 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.\n","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"],"x-platforms":["instagram","facebook","tiktok","youtube","twitter","threads","pinterest","reddit","bluesky","googlebusiness","linkedin"],"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 newly published post is retrievable within seconds instead of waiting for the background sync (which refreshes each account at most every ~90 minutes).\n\nPrimary 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.\n\nBehavior:\n- Account access and connection state are checked before any platform call, including requests inside the debounce window.\n- Inactive accounts or accounts marked `needsReconnection` return `409` with code `ads_connection_required`. Stop scheduled retries for that account until it is reconnected, then read `GET /v1/accounts` for its current account ID.\n- For connected accounts, we fetch the latest posts live from the platform, then match and return the submitted post.\n- Requests are debounced per account (~15s): if the account was synced inside that window, the live fetch is skipped.\n\n`accountId` is required, because 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 Profile, and LinkedIn organization accounts).\n\nLinkedIn 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).\n\n`url` accepts any format the platform uses (e.g. `instagram.com/p/…`, `instagram.com/reel/…`, `youtu.be/…`, `youtube.com/shorts/…`, `tiktok.com/@user/video/…`, `vm.tiktok.com` short links, `pinterest.com/pin/…` on any regional domain, and `pin.it` short links). Pass `postId` (the platform media/video/pin id) as an alternative locator.\n\nNote: 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.\n","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 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.\n","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 the account was synced within the debounce window and no live fetch ran."}}},"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"}}}},"401":{"description":"The platform authorization has expired. Reconnect the account."},"403":{"description":"The API key lacks access to this account or the platform denied access."},"404":{"description":"Account not found (or not owned by the authenticated user)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"description":"The account is inactive or needs reconnection. Stop scheduled retries until it is reconnected.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"This account needs reconnection. Reconnect the account, then read GET /v1/accounts for its current account ID before retrying.","type":"invalid_request_error","code":"ads_connection_required","param":"accountId"}}}},"502":{"$ref":"#/components/responses/UpstreamFailure"},"503":{"$ref":"#/components/responses/TemporarilyUnavailable"}}}},"/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.\n","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.\nPublished posts can only have their recycling config updated.\n\nTo promote a draft to scheduled, send `isDraft: false` together with `scheduledFor` (or `publishNow: true`,\nor `queuedFromProfile`). If `isDraft` is omitted the post keeps its current draft status, so sending only\n`scheduledFor` to a draft returns 200 but the post remains a draft.\n\nNon-draft updates run the same per-platform validation as post creation (media requirements, platform-specific\nfield rules, etc.) against the resulting platforms, returning 400 on failure.\n","parameters":[{"name":"postId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"title":{"type":"string","description":"Stored on the post for reference/display only. This field is NOT used as the video title when publishing. To set a YouTube video title, use platformSpecificData.title on the youtube platform target (falls back to the first line of content when omitted)."},"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","description":"Stored for reference only. Hashtags are NOT automatically appended to the caption when publishing. Include hashtags directly in the content field (platforms like Instagram only support hashtags as caption text). For YouTube keywords, use the tags field instead.","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":"The post was updated, but the inline publish that followed did not fully succeed.\n\n**207 is a 2xx status**, so `fetch(...).ok` is `true` and axios resolves. Branch on the status code explicitly.\n\nRead `post.status`: `partial` (some platforms published), `failed` (none published, terminal), or `scheduled` (transient errors, platforms reset to `pending`, Zernio retries automatically and this is not a failure). `platformResults` is omitted when the attempt aborted before producing per-platform results; `post.platforms[]` is always present.\n","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PostPublishIncompleteResponse"}}}},"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"},"409":{"description":"The requested scheduledFor collides with another post already occupying that slot in the same queue (code: queue_slot_conflict). Choose a different time, omit scheduledFor and let the queue assign the next open slot, or send queueId: null to schedule this post outside the queue.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}},"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.\n\nCSV columns:\n- Required: `platforms`, `profiles`, and a schedule (one of `schedule_time`, a `schedule_time_<platform>` override, `publish_now=true`, `use_queue=true`, or `is_draft=true`).\n- Content: at least one of `post_content`, `title`, or `media_urls` is required.\n- Aliases: a handful of columns accept the JSON field name from POST /v1/posts, since integrators infer the CSV shape from that endpoint's body. When both are present the real CSV column wins, unless it is blank for that row, in which case the alias value is used.\n  - `content` aliases `post_content`\n  - `timezone` aliases `tz`\n  - `scheduledFor` aliases `schedule_time`\n  - `mediaUrls` aliases `media_urls`\n- Per-platform overrides use three dynamic column prefixes, one column per platform (e.g. `schedule_time_instagram`, `custom_content_tiktok`, `custom_media_youtube`): `schedule_time_<platform>`, `custom_content_<platform>`, `custom_media_<platform>`.\n- Any other column is not read. It does not error, but it is reported in the response's `warnings` array as `unknown_columns:<a,b,c>` (see BulkUploadResult), so a misnamed or unsupported column is never silently dropped.\n- Row limits: 5000 rows is a hard cap that returns 400 above it. 500 rows is only an advisory threshold, it adds `rows_exceed_advisory_limit:500` to `warnings` and the request still processes.\n\nExample row (header + one data row):\n```\npost_content,platforms,profiles,schedule_time,tz\n\"Hello world\",instagram,MyProfile,2026-09-01 10:00,America/New_York\n```\n","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).\nA mix of successes and failures returns `207` instead, with the same body shape.\n","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\nshape to the `200` response. Inspect each entry in `results` (`ok` plus `errors`)\nto see which rows failed and why.\n","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).\n","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":"The retry ran, but publishing did not fully succeed. Covers both a partial publish and a retry in which no platform published.\n\n**207 is a 2xx status**, so `fetch(...).ok` is `true` and axios resolves. Branch on the status code explicitly.\n\nThis response carries no `platformResults`. Read `post.status` (`partial`, `failed`, or `scheduled` when transient errors will be retried automatically) and `post.platforms[]` for per-platform detail.\n","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"error":{"type":"string","description":"Summary of why the retry did not fully succeed."},"post":{"$ref":"#/components/schemas/Post"}}}}}},"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. Distinguish by the `code` field:\n- `ACCOUNT_NOT_ENABLED_FOR_POSTING`: a target account was connected for ads only (`enabled: false`) and cannot be posted to. Connect it as a posting account, then retry.\n- `PROFILE_OVER_LIMIT`: a target account belongs to a profile beyond the plan's profile limit.\n- `insufficient_permissions`: the post is not accessible to the caller.\n","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).\n","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.\nNot supported on Instagram, TikTok, or Snapchat. Threaded posts delete all items. YouTube deletion is permanent.\n","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, Discord,\nFacebook, Reddit, LinkedIn, Telegram, Pinterest, Google Business Profile, YouTube,\nand Slack. When a post was published to several accounts on the same platform,\npass `accountId` to pick which account's copy to edit (the first entry is edited\notherwise). Each platform enforces its own rules:\n\n**X**\n- Connected X account must have an active X Premium subscription\n- Must be within 1 hour of original publish time\n- Maximum 5 edits per tweet (enforced by X)\n- Threads cannot be edited, only single tweets\n- X assigns a NEW post ID on edit, returned as `id`\n\n**Discord**\n- No time limit and no premium requirement\n- The message ID is unchanged after the edit\n\n**Facebook**\n- Graph only permits editing a post that the same app created, so this works on\n  posts published through Zernio and is rejected for posts created in Meta\n  Business Suite / Composer or by another tool\n- Media cannot be swapped, only the message text\n- Reactions, comments, and shares are preserved. The post ID is unchanged\n\n**Reddit**\n- Self-posts only. A link post has no editable body and is rejected before the write\n- Body only. Reddit exposes no API to edit a post title, ever\n- The post ID is unchanged\n\n**LinkedIn**\n- Text only, no time limit. Media, polls, articles, and reshare targets cannot be\n  changed\n- Works for member and organization posts published through this API. The post\n  keeps its ID and LinkedIn shows an \"edited\" marker\n- Text is limited to 3,000 characters; mentions and hashtags are preserved\n\n**Telegram**\n- No time limit; messages published through Zernio are editable indefinitely\n- Text posts: edits the message text (up to 4096 characters)\n- Media posts: edits the caption only (up to 1024 characters). The media itself\n  cannot be swapped\n- For albums, the caption shown on the album (its first message) is edited\n- The message ID is unchanged\n\n**Pinterest**\n- Description only, maximum 800 characters. Media, link, and board cannot be\n  changed, and a pin title derived from the old content's first line at publish\n  stays as-is\n- Pinterest's pin-update endpoint is currently in closed beta; until the app is\n  allowlisted by Pinterest, edits are rejected with a \"beta feature not yet\n  enabled\" error\n- The pin ID is unchanged\n\n**Google Business Profile**\n- Post body (summary) text only. Call-to-action, event/offer fields, and media are\n  untouched\n- No time limit and no edit limit. The post ID is unchanged\n- The post must still exist on Google: a post deleted from the Business Profile\n  dashboard, or an event/offer post past its end date, returns a 404\n\n**YouTube**\n- `content` replaces the video description only. The title is unchanged, even if\n  it was originally derived from the content's first line at publish time\n- Title, tags, thumbnail, and privacy edits belong to `POST /v1/posts/{postId}/update-metadata`\n- No time window and no edit cap. The video ID is unchanged\n\n**Slack**\n- Text only, up to 4,000 characters. Media cannot be swapped, and media posts\n  whose share message reference never resolved cannot be edited\n- No time limit unless workspace admins restrict message editing\n- The message ID is unchanged\n\nMedia edits are not supported on any platform. The post record in Zernio is updated\nwith the new content and an edit-history entry.\n","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","linkedin","telegram","pinterest","googlebusiness","youtube","slack"]},"content":{"type":"string","description":"The new post text content"},"accountId":{"type":"string","description":"Which account's copy of the post to edit when the post was published to several accounts on the same platform; defaults to the first.\n"}}},"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; every other platform returns the original ID unchanged.\n"},"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), a Facebook post that was not created by this app, content over a platform's length limit (LinkedIn 3,000, Telegram 4096 text / 1024 caption, Pinterest 800, Slack 4,000), a Telegram edit with unchanged content, and a Pinterest app not yet allowlisted for the pin-update beta.\n"},"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.\nCurrently only supported for YouTube. At least one updatable field is required.\n\nTwo modes:\n\n1. Post-based (video published through Zernio): pass the Zernio postId in the URL and platform in the body.\n2. Direct video ID (video uploaded outside Zernio, e.g. directly to YouTube): use _ as the postId,\n   and pass videoId + accountId + platform in the body. The accountId is the Zernio account ID\n   for the connected YouTube channel.\n","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 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 team 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"},"lastLoginAt":{"type":"string","format":"date-time","description":"Last sign-in, stamped at most once an hour, so it is accurate to within an hour rather than to the exact session. Omitted for members with no recorded sign-in since the field shipped, which does not mean they never signed in."}}}}}},"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","lastLoginAt":"2024-06-02T09:12:00Z"},{"_id":"6507a1b2c3d4e5f6a7b8c9d1","name":"Jane Smith","email":"jane@example.com","role":"member","isRoot":false,"profileAccess":["64f0a1b2c3d4e5f6a7b8c9d0","64f0a1b2c3d4e5f6a7b8c9d1"],"createdAt":"2024-03-20T14:45:00Z","lastLoginAt":"2024-05-28T16:03: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 team: 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","minProperties":1,"properties":{"name":{"type":"string","minLength":1},"description":{"type":["string","null"],"description":"Set to null to clear the description."},"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, including a body that carries none of name, description, color or isDefault (code: missing_required_field)."},"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 accounts. Only includes accounts within the plan limit by default. Follower data requires analytics add-on.\nSupports optional server-side pagination via page/limit params. When omitted, returns all accounts (backward-compatible).\npage and limit must be supplied together; out-of-range page/limit values are rejected with 400 rather than silently clamped.\n","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.\n"},{"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.\n"},{"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.\n"}],"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"},"503":{"$ref":"#/components/responses/TemporarilyUnavailable"}}}},"/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 accounts.\nRequires analytics add-on subscription. Follower counts are refreshed once per day.\n","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 account's display name or username override.\n\nFor X accounts on usage-based billing, also accepts an `xCapabilities`\nobject to toggle background API operations that incur X API pass-through costs.\nBoth fields are opt-in (default `false`). When off, no analytics syncs or DM\npolling are performed for that account, and no API call is metered for those\noperations. Publishing and deleting posts are always available regardless of\nthese toggles. Setting `xCapabilities` on a non-X account returns 400.\n","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 only. Per-account opt-in toggles for background API\noperations that incur X API pass-through costs. Each call is\nbilled at the X tier rate. Either field can be\nsent independently; omitted fields are unchanged.\n","properties":{"analytics":{"type":"boolean","description":"Enable periodic analytics reads (impressions, likes, etc.)\nfor this X account. Each X API call is metered as\n`posts_read` and billed pass-through (~$0.005/call at the\ntime of writing; the actual rate depends on X's pricing tier).\n"},"inbox":{"type":"boolean","description":"Enable DM polling and inbox sync for this X account. DM\nreads are metered as `dm_event_read` (~$0.010/call) and\nDM sends as `dm_interaction_create` (~$0.015/call), both\nbilled pass-through. DM sends fire only on user-initiated\nactions; reads/polling fire only when this flag is true.\n"}}}}},"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\nwhen the request body included an `xCapabilities` object.\n","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 account to a different profile owned by the same\nuser. The target profile must belong to the same user as the account.\n\nFor API keys restricted to specific profiles, BOTH the source account's\ncurrent profile AND the target profile must be in the key's allowed set.\nCalls with a target profile outside the key's scope return 403.\n","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 account. Repeating the call for an account already disconnected returns 404, the account stays in its 1h grace window and the disconnect is not re-run.","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"}},"messagingRestriction":{"type":["object","null"],"description":"Observed from Meta's own error subcodes on our own sends (2534122, 1893063, 2534029), not a live probe. Set on the first refused send and cleared when a later send succeeds, so it lags reality by one send in each direction.","properties":{"subcode":{"type":"integer"},"message":{"type":"string"},"firstSeenAt":{"type":"string","format":"date-time"},"lastSeenAt":{"type":"string","format":"date-time"}}}}}}}},"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.\nUse it when the number has its own two-step verification PIN: the connect flows register\nwith a default PIN, Meta rejects that with error 133005, and the number then fails every\nsend with the misleading '(#200) You do not have the necessary permission to send messages'\nwhile the account still shows as connected. The PIN is used for this call only and is not stored.\n","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. Omitting it applies Zernio's managed default registration PIN, the same one every Embedded Signup connect sets automatically."}}},"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}/whatsapp/request-code":{"post":{"x-resource-group":"accounts","operationId":"requestWhatsAppVerificationCode","tags":["WhatsApp"],"summary":"Request a Meta re-verification code for a BYO WhatsApp number","description":"For a bring-your-own WhatsApp number (its own WABA, migrated off another BSP) that\nMeta demoted to re-verification, this requests a new OTP from Meta. The code lands\non the customer's own handset, so verifying it is necessarily self-service; call\nPOST /v1/accounts/{accountId}/whatsapp/verify-code with the code once it arrives.\nRate-limited to one request per 10 minutes per account, and Meta enforces its own\ncooldown on top of that.\n","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":{"method":{"type":"string","enum":["SMS","VOICE"],"default":"SMS"},"language":{"type":"string","default":"en_US","description":"Meta locale code for the verification message, e.g. en_US."}}}}}},"responses":{"200":{"description":"Code requested, or the number was already CONNECTED and no code was needed.","content":{"application/json":{"schema":{"type":"object","properties":{"requested":{"type":"boolean"},"alreadyActive":{"type":"boolean"},"method":{"type":"string"},"accountId":{"type":"string"},"phoneNumberId":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"Meta already reports this number as VERIFIED. Call POST /v1/accounts/{accountId}/whatsapp/register instead."},"422":{"description":"The account has no phone number bound yet, it runs in coexistence with the WhatsApp Business app, or Meta rejected the code request."},"429":{"description":"Our own 10-minute-per-account cooldown is active, or Meta has escalated to a multi-hour lockout after repeated attempts.","headers":{"Retry-After":{"description":"Seconds remaining until the cooldown or lockout clears.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"503":{"description":"Meta could not dispatch a code for this number yet. Retry shortly.","headers":{"Retry-After":{"description":"Seconds to wait before retrying.","schema":{"type":"integer"}}},"content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/accounts/{accountId}/whatsapp/verify-code":{"post":{"x-resource-group":"accounts","operationId":"verifyWhatsAppNumber","tags":["WhatsApp"],"summary":"Verify the Meta re-verification code for a BYO WhatsApp number","description":"Submits the OTP Meta sent in response to POST /v1/accounts/{accountId}/whatsapp/request-code.\nThis only verifies the number with Meta; it does not register it on the Cloud API.\nCall POST /v1/accounts/{accountId}/whatsapp/register afterward to complete activation.\n","parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"The WhatsApp account ID"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["code"],"properties":{"code":{"type":"string","description":"The 6-digit code Meta sent to the phone. Non-digit separators (e.g. \"749-456\") are stripped automatically."}}},"example":{"code":"749456"}}}},"responses":{"200":{"description":"Number verified with Meta","content":{"application/json":{"schema":{"type":"object","properties":{"verified":{"type":"boolean"},"accountId":{"type":"string"},"phoneNumberId":{"type":"string"}}}}}},"400":{"description":"The code is malformed, or Meta rejected it as wrong or expired."},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"The account has no phone number bound to it yet."}}}},"/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.\n\nFor WhatsApp accounts the response also includes `platformConnection`, a live probe of the\nMeta link behind the channel (the same read as `GET /v1/whatsapp/number-info`). The OAuth\ntoken can be perfectly valid while Meta refuses to serve the phone-number object (for\nexample after a phone-side coexistence disconnect), so `tokenStatus` alone is not a\nliveness signal for WhatsApp. When the Meta link is dead, `platformConnection.status` is\n`disconnected` and the overall `status` is `error`.\n","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"},"messagingRestriction":{"type":["object","null"],"description":"Observed from Meta's own error subcodes on our own sends (2534122, 1893063, 2534029), not a live probe. Set on the first refused send and cleared when a later send succeeds, so it lags reality by one send in each direction.","properties":{"subcode":{"type":"integer"},"message":{"type":"string"},"firstSeenAt":{"type":"string","format":"date-time"},"lastSeenAt":{"type":"string","format":"date-time"}}},"platformConnection":{"type":"object","description":"WhatsApp accounts only. Live probe of the Meta link behind the channel, performed at request time (the same read as GET /v1/whatsapp/number-info).","properties":{"status":{"type":"string","enum":["connected","disconnected","unknown"],"description":"`connected` = Meta served the channel object. `disconnected` = Meta refused to serve it (Graph error 100, subcode 33), which is how a phone-side coexistence disconnect surfaces. `unknown` = the live read failed for another reason (timeout, transient Meta error), not evidence either way."},"checkedAt":{"type":"string","format":"date-time","description":"When this live probe ran (always the current request; never cached)"},"phoneStatus":{"type":["string","null"],"description":"Meta's own `status` field from the phone-number node (for example CONNECTED), when the object was readable"},"metaError":{"type":["object","null"],"description":"Set only when status is `disconnected`","properties":{"code":{"type":"integer","description":"Meta Graph error code (100)"},"subcode":{"type":"integer","description":"Meta Graph error subcode (33)"},"message":{"type":"string"}}}}}}},"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":[]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"}}}},"/v1/accounts/{accountId}/posts":{"get":{"x-resource-group":"accounts","operationId":"getAccountPosts","tags":["Accounts"],"x-platforms":["facebook","instagram","twitter","bluesky","threads","youtube","linkedin","reddit","tiktok","pinterest"],"summary":"List posts published on the platform","description":"Returns the 25 most recent posts that exist on the platform for a connected account, read\nlive from the platform API. This covers everything on the account, including posts that\nwere never created through Zernio.\n\nUse it to obtain the platform's own post id, which the analytics endpoints take as input.\nOn YouTube the returned `id` is the video ID that `GET /v1/analytics/youtube/daily-views`,\n`/video-retention` and `/demographics` expect as `videoId`, so this endpoint is what backs\na video picker in your own UI.\n\nNot every field applies to every platform: `reactionCount` is Facebook and LinkedIn,\n`shareCount` is platform dependent, `cid` is the Bluesky content id needed to reply, and\n`subreddit` is Reddit only. Absent fields are omitted from the response.\n\nThe account's token is refreshed before the call when it has expired. When the refresh\ncannot recover it, the response is a 401 with code `TOKEN_EXPIRED` and the account has to\nbe reconnected.\n","parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Posts list","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","enum":["success"]},"posts":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"The platform's own post id (the video ID on YouTube)"},"platform":{"type":"string"},"message":{"type":"string","description":"Caption or title, empty string when the post has no text"},"createdTime":{"type":"string","format":"date-time"},"permalink":{"type":"string","description":"Public URL of the post on the platform"},"picture":{"type":"string","description":"Thumbnail or media URL"},"mediaType":{"type":"string"},"commentCount":{"type":"integer"},"likeCount":{"type":"integer"},"reactionCount":{"type":"integer","description":"Facebook and LinkedIn only"},"shareCount":{"type":"integer"},"cid":{"type":"string","description":"Bluesky content id, required to reply to the post"},"subreddit":{"type":"string","description":"Reddit only"}}}},"lastUpdated":{"type":"string","format":"date-time"}}},"example":{"status":"success","posts":[{"id":"dQw4w9WgXcQ","platform":"youtube","message":"How to schedule a post\n\nA short walkthrough.","createdTime":"2026-08-21T09:14:03.000Z","permalink":"https://www.youtube.com/watch?v=dQw4w9WgXcQ","picture":"https://i.ytimg.com/vi/dQw4w9WgXcQ/maxresdefault.jpg","mediaType":"video","commentCount":12,"likeCount":340}],"lastUpdated":"2026-08-28T11:02:44.512Z"}}}},"400":{"description":"Invalid accountId, platform does not support posts listing, or the account has no access token"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"X analytics capability not enabled for this account (code X_ANALYTICS_NOT_ENABLED)"},"404":{"$ref":"#/components/responses/NotFound"},"502":{"$ref":"#/components/responses/UpstreamFailure"},"503":{"$ref":"#/components/responses/TemporarilyUnavailable"}}}},"/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\naccount, plus their public profile counters.\n\n`userId` is the Instagram-scoped id (IGSID) Meta gives you on a webhook:\n`sender.id` on `message.received`, `comment.author.id` on `comment.received`.\n\n**Meta only answers for people who have MESSAGED the account.** Commenting grants\nno consent, so a commenter who has never DMed you is unresolvable - that is a\nplatform rule, not a limitation of this endpoint. When it cannot be resolved the\nresponse is still `200` with `isFollower: null` and an `unavailableReason`, because\n\"unknown\" is a normal state to branch on:\n\n  * `consent_required` - the user has never messaged this account.\n  * `dm_access_disabled` - the account owner turned off Instagram Direct API access.\n  * `not_messageable` - the id is not a messaging-scoped id.\n  * `error` - a transient Graph API failure.\n\nTo gate a comment automation on this, use the automation's `audience` rules instead\nof calling this per comment - they run the same lookup only on comments that\nactually match a keyword, and can ask the commenter to confirm with one tap.\n\nAnswers are cached briefly per (account, user). Pass `refresh=true` right after\nasking someone to follow, so a follow from a moment ago is visible.\n","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/commercial-music":{"get":{"x-resource-group":"accounts","operationId":"listTikTokCommercialMusic","tags":["Accounts"],"summary":"List trending commercial music","description":"Returns the 100 currently trending tracks of TikTok's Commercial Music Library for a TikTok account connected through the TikTok for Business app. Use a track id as tiktokSettings.musicSoundInfo.musicSoundId when creating a post. The list is not paged; countryCode selects the country chart.","parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"The TikTok account ID"},{"name":"countryCode","in":"query","required":false,"schema":{"type":"string","minLength":2,"maxLength":2},"description":"Two-letter ISO 3166-1 country code of the chart to read (for example ES). Defaults to TikTok's global chart."}],"responses":{"200":{"description":"The trending tracks, rank 1 first","content":{"application/json":{"schema":{"type":"object","properties":{"tracks":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"The id to send as musicSoundId (the full track's song clip id). TikTok rejects the commercial music id itself at publish time."},"commercialMusicId":{"type":"string","description":"TikTok's commercial_music_id, for reference only"},"name":{"type":"string"},"artist":{"type":"string"},"durationSec":{"type":"integer"},"genres":{"type":"array","items":{"type":"string"}},"previewUrl":{"type":"string","description":"Preview audio of the full track"},"thumbnailUrl":{"type":"string"},"rank":{"type":"integer","description":"Position in the trending chart, 1 first"},"clip":{"type":"object","description":"The trending excerpt of the track, when TikTok provides one. Its id is also accepted as musicSoundId.","properties":{"id":{"type":"string"},"durationSec":{"type":"integer"},"previewUrl":{"type":"string"}}}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"description":"Account not found"}}}},"/v1/accounts/{accountId}/tiktok/locations":{"get":{"x-resource-group":"accounts","operationId":"searchTikTokLocations","tags":["Accounts"],"summary":"Search TikTok location tags","description":"Searches the location tags a TikTok account connected through the TikTok for Business app can attach to a video post. Send a result's id and name as tiktokSettings.locationId and locationName when creating a post. TikTok answers the 20 closest matches and fills the list with fuzzy matches when nothing matches, so an unrelated result does not mean the place is missing.","parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"The TikTok account ID"},{"name":"query","in":"query","required":true,"schema":{"type":"string","minLength":1,"maxLength":100},"description":"Place name to search, for example a city, a venue or an address"}],"responses":{"200":{"description":"The closest location tags, best match first","content":{"application/json":{"schema":{"type":"object","properties":{"locations":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"The value to send as locationId"},"name":{"type":"string","description":"The value to send as locationName"},"address":{"type":"string","description":"Address or region TikTok shows for the place, for disambiguation"}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"description":"Account not found"}}}},"/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":"Per-interaction descriptors for the comment, duet and stitch toggles. Each key matches the tiktokSettings field of the same name on the create-post request. allow_duet and allow_stitch are null when mediaType is photo, because TikTok does not apply duet or stitch to photo posts.","properties":{"allow_comment":{"type":"object","description":"Descriptor for the allow_comment toggle.","properties":{"enabled":{"type":"boolean","description":"Whether the creator permits this interaction. False means they disabled it in the TikTok app. This is availability, never the value the user selected."},"required":{"type":"boolean","description":"Whether tiktokSettings.allow_comment must be supplied when creating a post. Always true, because TikTok forbids defaulting it."},"default":{"type":"boolean","description":"Initial value a post composer should render. A UI seed only, never applied server-side when the field is omitted."},"label":{"type":"string","description":"Human-readable toggle label."}}},"allow_duet":{"type":["object","null"],"description":"Descriptor for the allow_duet toggle. Null when mediaType is photo.","properties":{"enabled":{"type":"boolean","description":"Whether the creator permits this interaction. False means they disabled it in the TikTok app. This is availability, never the value the user selected."},"required":{"type":"boolean","description":"Whether tiktokSettings.allow_duet must be supplied when creating a post. Always true, because TikTok forbids defaulting it."},"default":{"type":"boolean","description":"Initial value a post composer should render. A UI seed only, never applied server-side when the field is omitted."},"label":{"type":"string","description":"Human-readable toggle label."}}},"allow_stitch":{"type":["object","null"],"description":"Descriptor for the allow_stitch toggle. Null when mediaType is photo.","properties":{"enabled":{"type":"boolean","description":"Whether the creator permits this interaction. False means they disabled it in the TikTok app. This is availability, never the value the user selected."},"required":{"type":"boolean","description":"Whether tiktokSettings.allow_stitch must be supplied when creating a post. Always true, because TikTok forbids defaulting it."},"default":{"type":"boolean","description":"Initial value a post composer should render. A UI seed only, never applied server-side when the field is omitted."},"label":{"type":"string","description":"Human-readable toggle label."}}}}}}},"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":{"allow_comment":{"enabled":true,"required":true,"default":false,"label":"Allow Comment"},"allow_duet":{"enabled":true,"required":true,"default":false,"label":"Allow Duet"},"allow_stitch":{"enabled":false,"required":true,"default":false,"label":"Allow Stitch"}}},"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 direct 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\nuser has authorized and that still hold a live token.\n\nRequires a session or a full-access API key. A profile-scoped API key, a\nrestricted (zrk_) API key, or an OAuth access token is rejected with 403: an\napp must not be able to enumerate its sibling authorizations, and connected-app\nmanagement is admin-plane.\n","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\nrevokes every live token it holds for the authenticated user. Takes effect on\nthe app's next request.\n\nIdempotent while the authorization is still on record: revoking an app that\nwas already revoked returns 200 with `revokedTokens: 0`.\n\nRequires a session or a full-access API key. A profile-scoped API key, a\nrestricted (zrk_) API key, or an OAuth access token is rejected with 403.\n","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.\nInvites expire after 7 days and are single-use.\n\nReturns 403 when a requested profile is not found or not owned, or when\ncalled with a restricted (zrk_) API key: invite management is admin-plane.\n","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.\nStandard 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.\n\nTikTok: every connection now goes through the TikTok for Business app. One TikTok account per\nprofile, so connecting on a profile that already holds one replaces it. Reconnecting the SAME\naccount keeps it and all of its history; authorizing a DIFFERENT TikTok account takes the slot\nover and permanently deletes the previous account's analytics, inbox and DM history. The two\nare told apart by the `@handle` stored at the last connect, so an account whose handle has\nbeen renamed on TikTok since then reads as a different account. An authorization that leaves\nout a permission the connected account needs changes nothing at all and comes back as\n`missing_tiktok_permissions`; connect again and accept every permission on TikTok's screen.\n","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. `snapchat` is a closed beta with no public release date: it returns 403 `PLATFORM_BETA_RESTRICTED` until the account is approved."},{"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. MUST be an absolute http(s) URL or a custom app scheme for mobile deeplinks (e.g. myapp://callback); a relative path is rejected with 400 INVALID_REDIRECT_URL. 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.\n\nOn failure, the browser is sent to the same redirect_url with `error` and `platform` appended.\n`error` and `platform` are always present. `error_message`, `is_user_fixable`, `reason` and\n`dashboard_url` are conditional and must be treated as optional.\n\nThis list is NOT exhaustive and new values may be added at any time. Treat an unrecognized\nvalue as a generic failure rather than matching it exhaustively. Existing values are not\nrenamed or removed without notice.\n\nOAuth and callback:\n  oauth_denied, invalid_callback, invalid_state, unsupported_platform, connection_failed,\n  internal_error, token_exchange_failed, byok_config_error, personal_account_not_supported,\n  missing_google_permissions, missing_tiktok_permissions, platform_requires_destination,\n  reconnect_account_mismatch, invalid_request\n\nAccess and limits:\n  profile_not_found, invalid_profile_id, access_denied, account_limit_exceeded,\n  profile_limit_exceeded, payment_required\n\nDestination selection:\n  no_facebook_pages, facebook_pages_error, no_google_locations, google_locations_error,\n  google_permission_denied, no_snapchat_public_profiles, snapchat_profiles_error,\n  discord_no_guild, slack_no_team\n\nWhatsApp:\n  whatsapp_error, one_whatsapp_per_profile, whatsapp_number_already_connected,\n  whatsapp_number_pinned_to_profile, connection_cancelled\n\nGoogle Ads (platform=googleads):\n  google_ads_auth_failed, google_ads_invalid_state, google_ads_config_error,\n  google_ads_token_failed, google_ads_quota_exhausted, google_ads_callback_error\n\nTikTok Ads (platform=tiktokads):\n  tiktok_ads_auth_failed, tiktok_ads_invalid_state, tiktok_ads_access_denied,\n  tiktok_ads_config_error, tiktok_ads_token_failed, tiktok_ads_account_not_found,\n  tiktok_ads_callback_error\n\nX Ads (platform=xads):\n  x_ads_denied, x_ads_auth_failed, x_ads_config_error, x_ads_account_not_found,\n  x_ads_state_error, x_ads_token_failed, x_ads_token_missing, x_ads_callback_error\n\nShopify (platform=shopify):\n  shopify_auth_failed, shopify_config_error, shopify_invalid_state, shopify_invalid_hmac,\n  shopify_invalid_shop, shopify_missing_scopes, shopify_callback_error\n\n1. On this endpoint every upstream OAuth error is collapsed into `oauth_denied`. The\nprovider's own value (for example Meta's `access_denied`) is not forwarded. The dedicated\nads flows below are different: they use their own denial slugs and `google_ads_auth_failed`\nand `tiktok_ads_auth_failed` may carry the provider's raw error string in `error_message`.\n\n2. On the tiktok and twitter ads flows `platform` carries the ads platform id\n(`tiktokads`, `xads`), not the value used in the request path. The googleads and shopify\nflows report `googleads` and `shopify`.\n\n3. `missing_tiktok_permissions` means the TikTok authorization left out a permission the\nalready-connected account needs, so nothing was changed and it keeps working as before.\nIt is user-fixable: connect again and accept every permission on TikTok's screen.\n"},{"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.\n\n`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.\n\n`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`.\n\n`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.\n"},{"name":"onboarding","in":"query","schema":{"type":"string","enum":["api","business_app"]},"description":"WhatsApp only. Ignored for every other platform. Controls which screen Meta's Embedded Signup popup shows.\n\nIf omitted, the connection defaults to coexistence (same as `business_app` below), preserving existing behavior for numbers already on the WhatsApp Business app.\n\n`api`: standard Embedded Signup, showing Meta's WABA/number picker. Use this to connect a phone number already on Cloud API elsewhere.\n\n`business_app`: coexistence, i.e. 'Connect existing WhatsApp Business app' (a number shared between Cloud API and the consumer WhatsApp Business app).\n"},{"name":"signup","in":"query","schema":{"type":"string","enum":["hosted"]},"description":"WhatsApp only. Rejected with 400 `INVALID_FIELD_VALUE` on any other platform.\n\n`hosted`: `authUrl` points at a Zernio-hosted page on zernio.com instead of Meta's OAuth dialog, and\nthe response carries `authUrl` only (no `state`). That page opens Meta's Embedded Signup popup itself,\nso it learns which WhatsApp Business Account and number the user picked inside the popup and connects\nexactly that one. Use it when your users' Facebook logins manage several WhatsApp accounts: on the\ndefault redirect flow Meta only returns an authorization code, so when that login can see more than\none number the user lands on Zernio's number picker and has to choose again. Nothing to embed on your\nside and no domain setup: send the user to `authUrl`, and they come back to `redirect_url` with the\nsame params as the redirect flow. Success: `connected=whatsapp`, `profileId`, `accountId`, `username`\n(plus `connect_token` for API-key callers). Failure: `error` and `platform=whatsapp`, with the same\nvalues and extras as the redirect flow (`one_whatsapp_per_profile`, `whatsapp_number_already_connected`\nand `whatsapp_number_pinned_to_profile` with `is_user_fixable=true`; `payment_required` with `reason`\nand `dashboard_url`; `whatsapp_error` with `error_message` when Meta reported one), plus two of its own:\n`connection_cancelled` when the popup was closed before finishing (`error_message` carries Meta's last\nreported step or error when there is one) and `session_expired` when the user took longer than the\n60 minute window the hosted page is valid for; restart the flow with a new call in that case.\n`onboarding` is carried through and a pre-verified Zernio-provisioned number is attached like on the\nredirect flow. `headless` has no effect here because there is no selection step left to hand you.\nWhen a previously disconnected account for this profile can simply be re-enabled, this endpoint\nre-enables it and returns the account directly instead of a URL, exactly like the redirect flow.\nThe page shows the same guidance as the Zernio dashboard: a pre-verified Zernio-provisioned number is\ncalled out by name (\"choose it in Meta's list, no code will be asked\"), coexistence and standard signups\nget their explainer video, and a step-by-step follow-along checklist stays visible while Meta's popup is\nopen. Skin it with `brandName`, `primaryColor` and `language` below.\n"},{"name":"brandName","in":"query","schema":{"type":"string","minLength":1,"maxLength":60},"description":"Hosted signup page only (`signup=hosted`, WhatsApp): name shown in the page title (\"Connect your WhatsApp number to <brandName>\") instead of Zernio. The Zernio logo stays: the page is co-branded, not white-label. Trimmed; 1 to 60 characters. Rejected with 400 `INVALID_FIELD_VALUE` without `signup=hosted`. Stored on the signup session at issue time, so the page URL cannot change it."},{"name":"primaryColor","in":"query","schema":{"type":"string","pattern":"^#[0-9a-fA-F]{6}$"},"description":"Hosted signup page only (`signup=hosted`, WhatsApp): hex colour (`#RRGGBB`) for the primary button and step accents. Validated server-side; anything else is a 400 `INVALID_FIELD_VALUE`. Rejected without `signup=hosted`."},{"name":"language","in":"query","schema":{"type":"string","enum":["en","es"]},"description":"Hosted signup page only (`signup=hosted`, WhatsApp): language of the page and its follow-along guide. Explainer videos stay in English. Default `en`. Rejected without `signup=hosted`."}],"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, or a non-absolute redirect_url)"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"description":"No access to profile, BYOK required for AppSumo X, or Snapchat closed beta (code PLATFORM_BETA_RESTRICTED)"},"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.\n\nFacebook, Google Business Profile, Snapchat and WhatsApp are not accepted here: their account identity is a destination chosen after OAuth, which this single-shot exchange cannot do. Connect them through the redirect flow from `GET /v1/connect/{platform}`, or, for WhatsApp Embedded Signup, through `POST /v1/connect/whatsapp/embedded-signup`.\n","parameters":[{"name":"platform","in":"path","required":true,"description":"Social platform to complete the connect for. Discord, Slack and Telegram are absent because they are\nserved by their own dedicated routes, documented separately.\n","schema":{"type":"string","enum":["instagram","twitter","threads","linkedin","youtube","tiktok","reddit","pinterest"]}}],"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, or the platform requires choosing a destination (code: platform_requires_destination)"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"description":"No access to the profile, or BYOK required for AppSumo X"},"404":{"description":"Profile not found"},"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)"},"503":{"description":"Connections for this platform are temporarily disabled (code: platform_disabled)"}}}},"/v1/connect/{platform}/ads":{"get":{"x-resource-group":"accounts","operationId":"connectAds","tags":["Connect"],"x-platforms":["meta","linkedin","tiktok","twitter","pinterest","google"],"summary":"Connect ads for a platform","description":"Unified ads connection endpoint. Creates a dedicated ads SocialAccount for the specified platform.\n\n**Meta business login (opt-in).** Set `loginMode=business` for `facebook` or\n`instagram` to use Facebook Login for Business and a Business Integration System User\ntoken. No posting account is created or required. This mode always returns an authUrl;\nit returns 503 when the server has no META_ADS_CONFIG_ID. Complete the dialog in a\nbrowser. The callback creates or reconnects only the metaads account, preserving its\nID, history and scopedAdAccountIds. Non-empty successful subscription results replace\nsubscribedAdAccountIds to remove stale grants; an empty result leaves routing unchanged. A reconnect must grant\nevery previously scoped ad account (or every previous grant for an unscoped connection).\nMissing or unverifiable grants return 409 before changing the account.\n\nPass `pageId` to select a granted Page for creatives and lead forms. API integrations\notherwise reuse the previous Page or sole granted Page. Multiple Pages without a selection\nreturn 400 with available Page IDs for API integrations; restart with pageId.\nDashboard session logins use the sole current grant automatically or open the existing\nFacebook Page picker for several grants, including reconnects. Selection completes\nthe Meta Ads connection. With no Pages granted the callback returns\n400 with instructions to connect again and grant a Page.\nSuccess redirects with connected=metaads, profileId and accountId.\nBusiness login reports metadata.tokenType=system-user in GET /v1/accounts. An absent\nMeta expires_in leaves tokenExpiresAt absent; no personal-token re-exchange occurs.\nSubsequent classic requests can change the ad-account scope using the business token;\nforce=true requires loginMode=business to reconnect that connection.\n\n**Same-token platforms (facebook, instagram, linkedin, pinterest).** The ads SocialAccount\n(metaads, linkedinads, pinterestads) reuses the OAuth token of the parent posting account,\nbut only when an active parent exists and, for facebook and instagram, its stored token\ncarries ads_management and ads_read (linkedin and pinterest need no extra scope). In that\ncase no extra OAuth happens and the response is alreadyConnected: true.\n\nWhen no such parent exists, or the scopes are missing, the endpoint returns an authUrl and a\nfull OAuth round trip is required. When a parent exists but carries no token usable for ad\naccounts, the call fails with 400 RECONNECT_REQUIRED. Independently of the branch, the call\ncan return 403 ADS_ADDON_REQUIRED without the ads add-on and 402 PAYMENT_REQUIRED when the\nbilling gate is closed.\n\nMeta Ads prerequisite: connecting Meta Ads (via facebook or instagram) requires a Facebook\nPage. Not because the ad account is read through a Page, but because both parent posting\naccounts are: the facebook flow only offers Pages you manage, and the instagram flow with\nloginMethod=facebook_login only offers Instagram accounts linked to one of those Pages.\nWithout a Page there is no parent account to inherit a token from. A user who manages no\nFacebook Page cannot complete this connection, and the facebook flow ends with\nerror=no_facebook_pages.\n\n**Separate-token platforms (tiktok, twitter).** Starts the platform-specific marketing API\nOAuth flow and creates an ads SocialAccount (tiktokads, xads) with its own token. If the ads\naccount already exists, returns alreadyConnected: true.\n  - tiktok: accountId is OPTIONAL. With accountId, the new tiktokads account links to that posting account (parentAccountId set), so 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).\n  - twitter (X Ads): accountId is REQUIRED. There's no ads-only mode, because tweets need to be authored by a real X user.\n\n**Standalone platforms (googleads).** Starts the Google Ads OAuth flow and creates a\nstandalone ads SocialAccount (googleads) with no parent. If the account already exists,\nreturns alreadyConnected: true.\n\nAds accounts appear as regular SocialAccount documents with ads platform values (e.g., metaads, tiktokads) in GET /v1/accounts.\n","parameters":[{"name":"loginMode","in":"query","schema":{"type":"string","enum":["classic","business"],"default":"classic"},"description":"Meta ads authorization mode. Business login is opt-in for Facebook and Instagram; classic preserves the posting-account flow.","example":"business"},{"name":"pageId","in":"query","schema":{"type":"string","pattern":"^\\d+$"},"description":"Business login only. Facebook Page ID to select from the token grants for ad creatives and lead forms.","example":"811889972008357"},{"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.\n\nIn classic mode, `instagram` requires an Instagram account connected with loginMethod=facebook_login whose\ntoken carries ads_management and ads_read. With an account connected through the default\ninstagram_login flow no ads account can be created; do not use this value for those accounts.\n"},{"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`:\nomit to enter ads-only mode (no TikTok posting account linked; ad creation uses\na Brand Identity instead of a TT_USER). Ignored for same-token (`facebook`,\n`instagram`, `linkedin`, `pinterest`) and standalone (`googleads`) platforms.\n"},{"name":"redirect_url","in":"query","schema":{"type":"string","format":"uri"},"description":"Custom URL the browser is sent to once the OAuth flow finishes. Honored on\nevery ads platform, including the separate-token (`tiktok`, `twitter`) and\nstandalone (`googleads`) flows. MUST be an absolute http(s) URL or a custom\napp scheme for mobile deeplinks (e.g. myapp://callback); a relative path is\nrejected with 400 INVALID_REDIRECT_URL. On success\n`tiktok`, `twitter` and `googleads` land on the URL unchanged, while the\nsame-token platforms (`facebook`, `instagram`, `linkedin`, `pinterest`)\nappend `connected`, `profileId`, `accountId`, `username` and, on API-key\ncalls, `connect_token`. On failure the same error contract applies as on\nGET /v1/connect/{platform}: `error` and `platform` are always appended,\nother params are optional, and the value list there is not exhaustive.\nOn the tiktok, twitter and googleads flows `platform` carries\nthe ads platform id (`tiktokads`, `xads`, `googleads`), not the value\nused in the request path. When omitted, the browser lands on\nthe Zernio dashboard.\n"},{"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\nendpoint returns `alreadyConnected: true` whenever a connected account\nis found, keying off its active state rather than token liveness.\nSet `force=true` to bypass that and always receivean `authUrl`.\nCompleting the returned OAuth refreshes the stored token\non the existing posting and ads accounts in place.\n"},{"name":"adAccountId","in":"query","schema":{"type":"string"},"description":"Scope ad sync to a single platform ad account. Without this param,\nsync covers every ad account the connected token can see. Business-login reconnects\npreserve the existing scope; supplied IDs are checked against the new grant. To change\nthat scope after migration, call this endpoint with the IDs and omit loginMode. Supported\non `facebook`/`instagram` (Meta, `act_<digits>`), `linkedin` (bare\nnumeric sponsored-account id), `googleads` (bare customer id digits)\nand `twitter` (X Ads, base36 account id). `tiktok` scopes advertisers\nat OAuth and `pinterest` has no ads discovery, so both ignore it.\nMeta ids are additionally validated against the connected token;\nunreachable IDs return 400. Setting a scope also removes already\nsynced ads from de-scoped ad accounts. For multiple accounts use\n`adAccountIds` instead.\n","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\nsupport and id shapes as `adAccountId`). Repeat the param\n(`?adAccountIds=act_1&adAccountIds=act_2`) or comma-separate\n(`?adAccountIds=act_1,act_2`). Persisted server-side; latest call\nwins, and de-scoped ad accounts have their synced ads removed.\nOmitting both `adAccountId` and `adAccountIds` keeps any previously\npersisted scope unchanged.\n"}],"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"},"tokenType":{"type":"string","enum":["system-user"],"description":"Present for an existing business-login connection."},"scopedAdAccountIds":{"type":"array","items":{"type":"string"},"description":"Echo of the persisted ad-account scope when the caller passed\n`adAccountId` / `adAccountIds`. Omitted when no scope is set.\n","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"}},"businessLogin":{"summary":"Meta ads business login","value":{"authUrl":"https://www.facebook.com/v24.0/dialog/oauth?client_id=APP_ID&config_id=CONFIG_ID&response_type=code&override_default_response_type=true&redirect_uri=https%3A%2F%2Fzernio.com%2Fapi%2Fv1%2Fconnect%2Fmeta-ads%2Fcallback&state=ENCRYPTED_STATE","state":"ENCRYPTED_STATE"}},"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, missing accountId for X Ads, or a non-absolute redirect_url"},"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"},"409":{"description":"Reconnect a system-user connection with loginMode=business."},"503":{"description":"Business login is not configured or the platform is temporarily unavailable."}}}},"/v1/connect/meta-ads/callback":{"get":{"operationId":"completeMetaAdsBusinessLogin","summary":"Complete Meta business login","tags":["Connect"],"x-platforms":["meta"],"x-resource-group":"accounts","security":[],"description":"Facebook Login for Business redirect target. Meta supplies the single-use authorization code and the authenticated state returned by connectAds. The state expires after 30 minutes and binds the user, profile, Page selection and ad-account scope. No bearer token is sent by the browser. Success reconnects only metaads and redirects to the original redirect_url. Invalid state returns 400; inaccessible profiles or missing ads access cannot connect. Dashboard logins with several Pages redirect to the Facebook Page picker with an encrypted selectionToken valid for ten minutes. Listing and selecting require the initiating user and current profile access. No plaintext platform token is returned to the browser.","parameters":[{"name":"state","in":"query","required":true,"schema":{"type":"string"},"description":"Authenticated state from the initial connectAds response.","example":"ENCRYPTED_STATE"},{"name":"code","in":"query","schema":{"type":"string"},"description":"Single-use authorization code returned by Meta."},{"name":"error","in":"query","schema":{"type":"string"},"description":"Meta authorization error when the user declines the dialog."}],"responses":{"307":{"description":"Redirect to the original callback URL with connected=metaads, profileId and accountId on success; authorization denial redirects with an error. Dashboard logins requiring a Page choice redirect to /connect/facebook/select-page with selectionToken.","headers":{"Location":{"schema":{"type":"string","format":"uri"},"example":"https://example.com/callback?connected=metaads&profileId=PROFILE_ID&accountId=ACCOUNT_ID"}}},"400":{"$ref":"#/components/responses/BadRequest"},"403":{"description":"Ads access or profile access required."},"409":{"description":"The new token grants do not match the existing connection, or its previous grants cannot be verified."},"503":{"description":"Business login state signing is not configured."}}}},"/v1/connect/shopify":{"get":{"x-resource-group":"accounts","operationId":"getShopifyConnectUrl","tags":["Connect"],"summary":"Get Shopify OAuth connect URL","description":"Initiate the Shopify OAuth flow for a store. Shopify is a connect-only\nplatform: the connected account does not publish social posts, it powers\nthe Blogs API (`/v1/accounts/{accountId}/blogs`). Returns an `authUrl`\nto redirect the merchant to; after they approve the install, Shopify\nredirects their browser to Zernio's callback, the account is created on\nthe profile (platform `shopify`), and the browser is redirected to\n`redirect_url` (or the Zernio dashboard when omitted). Requested scopes\nare `read_content` and `write_content` (content only; no customer or\norder data). Connecting the same profile to a store again refreshes the\nstored token in place.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"profileId","in":"query","required":true,"schema":{"type":"string"},"description":"Your Zernio profile ID (get from /v1/profiles)."},{"name":"shop","in":"query","required":true,"schema":{"type":"string"},"description":"The myshopify.com store domain to connect, e.g. `your-store.myshopify.com` (the bare `your-store` prefix is accepted too)."},{"name":"redirect_url","in":"query","schema":{"type":"string","format":"uri"},"description":"Your custom redirect URL after connection completes. MUST be an absolute http(s) URL or a custom app scheme for mobile deeplinks (e.g. myapp://callback); a relative path is rejected with 400 INVALID_REDIRECT_URL. On failure an `error` query param is appended."}],"responses":{"200":{"description":"OAuth authorization URL to redirect the merchant 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)"}}}}}},"400":{"description":"Invalid `profileId` format, `shop` is not a myshopify.com store domain, or `redirect_url` is not an absolute http(s) URL or custom app scheme."},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"description":"API key does not have access to this profile."},"404":{"description":"Profile not found or access denied."},"500":{"description":"Shopify API not configured (missing credentials)."}}}},"/v1/connect/shopify/token":{"post":{"x-resource-group":"accounts","operationId":"connectShopifyWithToken","tags":["Connect"],"summary":"Connect a Shopify store with a custom-app Admin token","description":"Token-paste alternative to the OAuth flow: connect a store using the\nAdmin API access token of a custom app the merchant created in their\nown Shopify admin (Settings → Apps and sales channels → Develop apps,\nwith the `read_content`/`write_content` scopes). Use this when the\none-click OAuth connect is unavailable or when your users prefer not\nto install a third-party app on their store. The token is validated\nagainst the store before anything is saved; custom-app tokens do not\nexpire. Connecting the same profile to a store again replaces the\nstored token in place.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["profileId","shop","accessToken"],"properties":{"profileId":{"type":"string","description":"Your Zernio profile ID (get from /v1/profiles)."},"shop":{"type":"string","description":"The myshopify.com store domain, e.g. `your-store.myshopify.com` (the bare `your-store` prefix is accepted too)."},"accessToken":{"type":"string","description":"Admin API access token of the merchant-created custom app (starts with `shpat_`)."}}}}}},"responses":{"200":{"description":"Store connected as a platform account","content":{"application/json":{"schema":{"type":"object","properties":{"account":{"type":"object","properties":{"_id":{"type":"string"},"platform":{"type":"string","enum":["shopify"]},"username":{"type":"string","description":"The myshopify.com domain of the connected store."},"displayName":{"type":"string"},"profileId":{"type":"string"}}}}}}}},"400":{"description":"Invalid `profileId` format, `shop` is not a myshopify.com store domain, or Shopify rejected the access token for that store."},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"description":"API key does not have access to this profile."}}}},"/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\n`tiktokads` SocialAccount. TikTok requires every ad to carry an\n`identity_id + identity_type` pair. The Brand Identity is the\nCUSTOMIZED_USER alternative to attributing ads to a real @username\n(TT_USER). This route uploads the supplied image to TikTok, creates\nthe identity via `/v2/identity/create/`, and caches the resulting\n`identity_id` on the account so subsequent `POST /v1/ads/create`\ncalls can opt into it via `identityType: 'CUSTOMIZED_USER'`.\n\nConfigurable on every `tiktokads` account, including linked-mode ones\n(those with a posting account on the same profile). Configuration is\nidempotent and harmless when posting is also connected: the default\nad-create path still prefers TT_USER, and CUSTOMIZED_USER is only used\nper-ad when the caller explicitly opts in.\n\nTikTok identities are immutable post-creation. Re-saving creates a new\nidentity on TikTok and swaps the cached id; the old identity stays\norphaned on TikTok's side (harmless, no billing impact).\n\nAlternative: pass `brandIdentity` directly on `POST /v1/ads/create` to\nconfigure on first ad creation in a single round-trip.\n","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"],"x-platforms":["facebook","meta"],"summary":"List Facebook pages","description":"Returns Facebook Pages after OAuth. Classic connections require profileId and tempToken from the OAuth redirect. Use X-Connect-Token for headless connections. The dashboard business-login picker instead sends only selectionToken, an encrypted grant valid for ten minutes. This requires the initiating user and current profile access and returns only Page IDs and names. X-Connect-Token cannot authorize business selection.","parameters":[{"name":"profileId","in":"query","required":false,"schema":{"type":"string"},"description":"Profile ID from your classic connection flow. Required with tempToken."},{"name":"tempToken","in":"query","required":false,"schema":{"type":"string"},"description":"Temporary Facebook access token from the classic OAuth callback. Required with profileId."},{"name":"selectionToken","in":"query","schema":{"type":"string"},"description":"Encrypted dashboard business-login grant. Send alone instead of profileId and tempToken. Expires after ten minutes.","example":"ENCRYPTED_SELECTION_TOKEN"}],"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":"Invalid or expired selectionToken, no granted Pages, or missing classic profileId and tempToken."},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"The caller is not the initiating user or no longer has profile access."},"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"],"x-platforms":["facebook","meta"],"summary":"Select Facebook page","description":"Complete a classic Facebook Page connection with profileId, pageId, tempToken and userProfile. Use X-Connect-Token for headless connections. The dashboard business-login picker instead sends only selectionToken and pageId to complete a Meta Ads connection. The server verifies the initiating user, profile access, current grants and connection eligibility. The profile, platform token, ad-account scope and return URL come only from the encrypted grant. Business selection requires a session or bearer authentication for the initiating user; X-Connect-Token is not accepted. It returns redirect_url with connected=metaads on success or an eligibility error redirect.","requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"type":"object","required":["profileId","pageId","tempToken","userProfile"],"not":{"required":["selectionToken"],"properties":{"selectionToken":{}}},"properties":{"profileId":{"type":"string","description":"Profile ID from your classic 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."}}},{"type":"object","additionalProperties":false,"required":["selectionToken","pageId"],"properties":{"selectionToken":{"type":"string","description":"Encrypted dashboard business-login grant. Expires after ten minutes.","example":"ENCRYPTED_SELECTION_TOKEN"},"pageId":{"type":"string","pattern":"^\\d+$","description":"A Page ID from the granted Pages returned by listFacebookPages."}}}]},"example":{"profileId":"507f1f77bcf86cd799439011","pageId":"123456789","tempToken":"EAAxxxxx...","userProfile":{"id":"987654321","username":"john.doe","displayName":"John Doe","profilePicture":"https://...","profileUrl":"https://facebook.com/987654321","bio":null},"redirect_url":"https://yourdomain.com/integrations/callback"}}}},"security":[{"bearerAuth":[]},{"connectToken":[]}],"responses":{"200":{"description":"Facebook Page connected or business-login redirect returned.","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"redirect_url":{"type":"string","description":"Redirect URL when a custom redirect_url was provided or a business Page was selected."},"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":"Invalid or expired selectionToken, invalid Page choice, forbidden grant overrides, or missing classic connection fields."},"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\nwas initiated as a `force=true` token-recovery re-auth\n(`GET /v1/connect/{platform}/ads`), but the grant landed on a different\nFacebook user or page than the connected account. The existing account\nis left untouched.\n","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\".\n\nAfter 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.\n\nNot used by the default `instagram_login` flow, which creates the account without a selection step.\n","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.\n\nOne 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.\n","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 Google Business Profile locations","description":"For headless flows. Returns the list of Google Business Profile 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.\n","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.\n"},{"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.\n"}],"security":[{"bearerAuth":[]},{"connectToken":[]}],"responses":{"200":{"description":"List of Google Business Profile 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 Profile 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.\n"}}},"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 Google Business Profile location","description":"Complete the headless Google Business Profile 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.\n","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 Profile location ID selected by the user"},"accountId":{"type":"string","description":"Optional but recommended. The Google Business Profile 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.\n"},"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 Profile 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 Google Business Profile location id. Combine with the Google Business Profile 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 Profile connection"}}}},"/v1/accounts/{accountId}/gmb-reviews":{"get":{"x-resource-group":"engagement","operationId":"getGoogleBusinessReviews","tags":["GMB Reviews"],"summary":"Get reviews","description":"Returns reviews for a Google Business Profile 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":{"$ref":"#/components/schemas/GoogleBusinessReview"}},"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 Profile 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"}}}},"502":{"$ref":"#/components/responses/UpstreamFailure"},"503":{"$ref":"#/components/responses/TemporarilyUnavailable"}}}},"/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 Profile 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 Google Business Profile 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 Profile 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 Google Business Profile 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 Google Business Profile 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.\n`title` and `metadata` are always included in the response so the `location` summary block can be populated, even if you omit them here.\nNote: `location` is a derived response field, not a Google readMask value, passing it returns 400.\n"}],"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\nfor surfacing the \"leave a review\" URL (e.g. behind a QR code) without\nparsing the raw block. Always populated regardless of readMask.\nFor unverified or new locations Google omits placeId/reviewUrl/mapsUri,\nso those return as null and `isVerified` is false.\n","properties":{"name":{"type":["string","null"],"description":"Business name as set in Google Business Profile"},"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\nincludes a value that is not a valid Google Business Information\nfield (e.g. `location`, which is a response-only derived field).\n","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 Google Business Profile location details. The updateMask field is required and specifies which fields to update.\nThis endpoint proxies Google's Business Information API locations.patch, so any valid updateMask field is supported.\nCommon fields: regularHours, specialHours, profile.description, websiteUri, phoneNumbers, categories, serviceItems.\n","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"],"additionalProperties":true,"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"}}}}}},"title":{"type":"string","description":"Business name. Use updateMask='title'."},"storeCode":{"type":"string","description":"External store identifier, unique within the account. Use updateMask='storeCode'."},"labels":{"type":"array","description":"Free-form, internal-only labels for grouping (1-255 characters each). Use updateMask='labels'.","items":{"type":"string"}},"storefrontAddress":{"type":"object","description":"Postal address of the storefront. Use updateMask='storefrontAddress'. Omit for service-area-only businesses.","properties":{"regionCode":{"type":"string","description":"ISO 3166-1 alpha-2 country code (e.g. 'BR')"},"languageCode":{"type":"string"},"postalCode":{"type":"string"},"administrativeArea":{"type":"string","description":"State or province"},"locality":{"type":"string","description":"City"},"addressLines":{"type":"array","items":{"type":"string"}}}},"serviceArea":{"type":"object","description":"Areas the business serves. Use updateMask='serviceArea'. Full replacement: send every place you want to keep.","properties":{"businessType":{"type":"string","enum":["CUSTOMER_LOCATION_ONLY","CUSTOMER_AND_BUSINESS_LOCATION"]},"places":{"type":"object","properties":{"placeInfos":{"type":"array","items":{"type":"object","properties":{"placeName":{"type":"string","description":"Localized area name (e.g. 'Sao Paulo, SP, Brasil')"},"placeId":{"type":"string","description":"Google Place ID for the area"}}}}}},"regionCode":{"type":"string","description":"Immutable. CLDR region code of the country the business is based in (e.g. 'BR')"}}},"openInfo":{"type":"object","description":"Open/closed status of the location. Use updateMask='openInfo'.","properties":{"status":{"type":"string","enum":["OPEN","CLOSED_PERMANENTLY","CLOSED_TEMPORARILY"]},"openingDate":{"type":"object","properties":{"year":{"type":"integer"},"month":{"type":"integer"},"day":{"type":"integer"}}}}},"moreHours":{"type":"array","description":"Additional hours for specific services (delivery, drive-through, etc.). Use updateMask='moreHours'.","items":{"type":"object","properties":{"hoursTypeId":{"type":"string","description":"Hours type from Google's catalog (e.g. 'DELIVERY', 'DRIVE_THROUGH')"},"periods":{"type":"array","items":{"type":"object","properties":{"openDay":{"type":"string"},"openTime":{"type":"string"},"closeDay":{"type":"string"},"closeTime":{"type":"string"}}}}}}},"latlng":{"type":"object","description":"Precise coordinates. Use updateMask='latlng'. Google restricts latlng writes to approved clients, so this update may be silently ignored or rejected.","properties":{"latitude":{"type":"number"},"longitude":{"type":"number"}}},"adWordsLocationExtensions":{"type":"object","description":"Alternate phone shown in Google Ads location extensions. Use updateMask='adWordsLocationExtensions'.","properties":{"adPhone":{"type":"string"}}}}},"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.\nReturns photo URLs, descriptions, categories, and metadata.\n","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.\n\nCategories determine where the photo appears: CATEGORY_UNSPECIFIED, COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, PRODUCT, FOOD_AND_DRINK, MENU, COMMON_AREA, ROOMS, TEAMS, AT_WORK, ADDITIONAL.\n","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 Google Business Profile 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\na location or business category. Use this endpoint to discover valid attribute names,\nvalue types, and allowed enum values before reading or writing via gmb-attributes.\n\nTwo mutually exclusive query modes:\n\n**Location mode**: pass `locationId` (or rely on the account's stored `selectedLocationId`).\nGoogle returns attributes valid for that specific location.\n\n**Category mode**: pass `categoryName` (must start with `categories/`) and `regionCode`.\nGoogle returns attributes valid for that category across the given region.\n`languageCode` is optional in category mode.\n\nBoth modes support `pageSize` and `pageToken` for pagination.\n","parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"}},{"name":"locationId","in":"query","schema":{"type":"string"},"description":"Google Business Profile location ID (e.g. \"6257659026299438786\"). If omitted, uses the account's stored selectedLocationId. Mutually exclusive with categoryName.\n"},{"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.\n"},{"name":"regionCode","in":"query","schema":{"type":"string"},"description":"BCP-47 region code (e.g. \"US\", \"ES\"). Required when categoryName is provided.\n"},{"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.\n"},{"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 Google Business Profile 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.).\n\nThe attributeMask specifies which attributes to update (comma-separated).\n","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.\n\nPlace actions are the booking, ordering, and reservation buttons that appear on your listing.\n","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.\n\nAvailable action types: APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE.\n","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 Google Business Profile 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).\nOnly the fields included in the request body will be updated.\n","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.\nReturns an array of service items (structured or free-form with optional price).\n","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.\nGoogle's API requires full replacement; individual item updates are not supported.\nEach service can be structured (using a predefined serviceTypeId) or free-form (custom label).\n","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.\nMore efficient than calling GET /gmb-reviews per location for multi-location businesses.\nReturns a flat locationReviews array (not grouped by location): each item carries\nthe location resource name it belongs to (`name`) plus the review object (`review`),\nwhose identity is `review.reviewId`.\nReviews are requested from Google ordered by `orderBy` (default `updateTime desc`,\nnewest first), so callers polling for recent reviews can stop paginating once they\ncross their date window.\nNote: this endpoint does not return aggregate metrics (averageRating / totalReviewCount).\nFor those, use the single-location GET /gmb-reviews endpoint.\n","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}":{"get":{"x-resource-group":"engagement","operationId":"getGoogleBusinessReview","tags":["GMB Reviews"],"summary":"Get a review","description":"Returns one Google Business Profile review, in the same shape as the entries of GET /v1/accounts/{accountId}/gmb-reviews.\nThe review is read from the account's selected location unless locationId overrides it, and Google returns 404 for a review id that belongs to another location.\nRead the review before replying if a human may have answered it already: replies are overwritten in place and Google keeps no history.\n","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"},{"name":"locationId","in":"query","schema":{"type":"string"},"description":"Override which location to read the review from. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Review fetched successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"accountId":{"type":"string"},"locationId":{"type":"string"},"review":{"$ref":"#/components/schemas/GoogleBusinessReview"}}},"example":{"success":true,"accountId":"64e1f0a9e2b5af0012ab34cd","locationId":"9281089117903930794","review":{"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":[]}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"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":{"description":"Account not found, or no such review on this location","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"500":{"description":"Failed to fetch the review","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 Profile review.\nThe reply is associated with the account's currently selected location (set via /v1/accounts/{accountId}/gmb-locations).\nCalling this endpoint a second time on the same review overwrites the previous reply (PUT semantics on Google's side).\nGoogle keeps no history, so an automated retry silently replaces a reply someone edited by hand in the Google Business Profile UI.\nRead the review before retrying if a human may have answered it.\n","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-Google Business Profile 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 Profile 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-Google Business Profile 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.\n\n**Scope**: This endpoint is used for LinkedIn organizations, Google Business Profile locations, Slack channels, 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 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`).\n\nReading the token does not consume it, so this fetch is repeatable until the token expires 1 hour after issuance. Completing the platform selection deletes the pending record, so the token stops working from then on. No authentication required.\n","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, only 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.\n","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"}},"betaRestricted":{"value":{"error":"Snapchat integration is currently in beta. Please wait until it is publicly released.","code":"PLATFORM_BETA_RESTRICTED"}}}}}},"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.\nTo get your userId for the state parameter, call GET /v1/users which includes a currentUserId field.\n","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.\n\nThe key grants full campaign write access on OpenAI's side (OpenAI does\nnot offer a read-only key scope). Zernio uses it to read ads and\nperformance, and to create and manage campaigns you set up through\nZernio (create, status, budget, and cancel). Campaigns created\ndirectly in ChatGPT Ads Manager can still be managed there.\n","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)."},"402":{"$ref":"#/components/responses/PaymentRequired"},"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.\nThis is the headless alternative to the Embedded Signup browser flow.\n\nTo get the required credentials:\n1. Go to Meta Business Suite (business.facebook.com)\n2. Create or select a WhatsApp Business Account\n3. In Business Settings > System Users, create a System User\n4. Assign it the whatsapp_business_management and whatsapp_business_messaging permissions\n5. Generate a permanent access token\n6. Get the WABA ID from WhatsApp Manager > Account Tools > Phone Numbers\n7. Get the Phone Number ID from the same page (click on the number)\n\nWarning: connecting subscribes your own Meta app to this WABA with an override\ncallback that redirects its webhook delivery to Zernio. This WABA's events stop\nreaching any callback URL you had configured before, immediately and with no\noverlap window. Do not unsubscribe your app from the WABA afterward: that also\ncuts off Zernio's delivery, and recovery requires calling this endpoint again.\n","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."},"webhookNotice":{"type":"string","description":"Present when the WABA webhook subscription (with the Zernio override callback) succeeded. Explains the delivery cutover and warns against unsubscribing the app from the WABA afterward."},"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"},"phoneNumber":{"type":"string","description":"The connected phone number"},"verifiedName":{"type":"string","description":"Meta-verified business name for the phone number"},"qualityRating":{"type":"string","description":"Meta quality rating for the phone number (e.g. GREEN, YELLOW, RED, UNKNOWN)"}}}}},"example":{"message":"WhatsApp connected successfully","account":{"accountId":"6507a1b2c3d4e5f6a7b8c9d0","platform":"whatsapp","username":"+1 555-123-4567","displayName":"Acme Corp","isActive":true,"phoneNumber":"+1 555-123-4567","verifiedName":"Acme Corp","qualityRating":"GREEN"}}}}},"400":{"description":"Invalid request. Missing fields, a `pin` that is not 6 digits, or the\nphoneNumberId was not found in the specified WABA. If the phone was not\nfound, the response includes availablePhoneNumbers to help identify the\ncorrect ID.\n"},"401":{"description":"Invalid or expired access token"},"403":{"description":"Profile limit exceeded for this plan"},"409":{"description":"Conflict with an existing connection. Either the phone number is a Zernio-provisioned number pinned to a different profile (code WHATSAPP_NUMBER_PINNED_TO_PROFILE, connect it from that profile or move it first with PATCH /v1/whatsapp/phone-numbers/{id}/profile), or the number is already actively connected on another profile or team (code WHATSAPP_NUMBER_ALREADY_CONNECTED, disconnect it there first). A number can only be live on one 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.\n\nWhatsApp 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.\n\nUse the `profileId` and `tempToken` returned in the headless redirect (`step=select_phone_number`).\n\nAlternative: if you already know `wabaId` and `phoneNumberId` (e.g. from Meta Business Suite), use `connectWhatsAppCredentials` instead, which skips this two-step flow.\n","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.\n","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":"Conflict with an existing connection. One of: the target profile already has a WhatsApp number connected (code ONE_WHATSAPP_PER_PROFILE, each profile holds exactly one WhatsApp number, so connect this number to a different or new profile); the phone number is a Zernio-provisioned number pinned to a different profile (code WHATSAPP_NUMBER_PINNED_TO_PROFILE, connect it from that profile or move it first with PATCH /v1/whatsapp/phone-numbers/{id}/profile); or the number is already actively connected on another profile or team (code WHATSAPP_NUMBER_ALREADY_CONNECTED, disconnect it there first). A number can only be live on one profile."},"500":{"description":"Failed to bind phone number","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}}}}},"/v1/connect/whatsapp/embedded-signup":{"post":{"x-resource-group":"accounts","operationId":"connectWhatsAppEmbeddedSignup","tags":["Connect"],"summary":"Connect WhatsApp from Embedded Signup","description":"Exchange the authorization code Meta's Embedded Signup popup returned. This is the call the Zernio-hosted\nsignup page makes after the popup closes (`GET /v1/connect/whatsapp?signup=hosted`), sending the `wabaId`\nand `phoneNumberId` Meta reported so exactly the chosen number is connected; when both are omitted the\nfirst number the token can see is used. The code never passes through a `redirect_uri`, so\n`POST /v1/connect/{platform}` cannot accept it. Authenticates with an API key, or with the connect token\nthe hosted flow issues (`X-Connect-Token` header).\n","parameters":[{"name":"X-Connect-Token","in":"header","required":false,"schema":{"type":"string"},"description":"Connect token issued by the hosted signup flow, accepted instead of an API key."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["code","profileId"],"properties":{"code":{"type":"string","description":"Authorization code from the WA_EMBEDDED_SIGNUP postMessage"},"profileId":{"type":"string"},"wabaId":{"type":"string","description":"WhatsApp Business Account id, when the SDK reported one"},"phoneNumberId":{"type":"string"},"isCoexistence":{"type":"boolean","description":"Number is also live in the WhatsApp Business app"},"expectedPhoneNumber":{"type":"string","description":"Rejects the connect when Meta returns a different number"},"redirectUrl":{"type":"string","description":"Hosted signup page only. When present, the response also carries `redirectUrl`, the URL the user should land on, with the outcome mapped exactly like the redirect flow (success params, or `error` and `platform` with the same values). Must be an absolute http(s) URL or a custom app scheme."},"echoConnectToken":{"type":"boolean","description":"Hosted signup page only. Append the connect token to the success redirect, as the redirect flow does for API-key callers."}}}}}},"responses":{"200":{"description":"Number connected","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"account":{"type":"object","properties":{"accountId":{"type":"string"},"platform":{"type":"string","enum":["whatsapp"]},"username":{"type":"string"},"displayName":{"type":"string"},"isActive":{"type":"boolean"},"selectedPhoneNumber":{"type":"string"}}},"redirectUrl":{"type":"string","description":"Present only when `redirectUrl` was sent; also present on error responses."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"409":{"description":"The number is already connected on another profile or team"}}}},"/v1/connect/whatsapp/sdk-config":{"get":{"x-resource-group":"accounts","operationId":"getWhatsAppSdkConfig","tags":["Connect"],"summary":"Get Embedded Signup SDK config","description":"The Meta app id and Embedded Signup configuration id the Zernio-hosted signup page uses to open Meta's\npopup. Integrators do not need this endpoint: start the hosted flow with\n`GET /v1/connect/whatsapp?signup=hosted` and send the user to the returned `authUrl`. Authenticates with\nan API key or with the connect token the hosted flow issues (`X-Connect-Token` header).\n","parameters":[{"name":"X-Connect-Token","in":"header","required":false,"schema":{"type":"string"},"description":"Connect token issued by the hosted signup flow, accepted instead of an API key."}],"security":[{"bearerAuth":[]}],"responses":{"200":{"description":"Meta app configuration for Embedded Signup","content":{"application/json":{"schema":{"type":"object","required":["appId","configId","branding"],"properties":{"appId":{"type":"string","description":"Meta app id"},"configId":{"type":"string","description":"Embedded Signup configuration id"},"branding":{"type":["object","null"],"description":"Skin chosen when the hosted signup session was issued (`brandName`, `primaryColor`, `language` on `GET /v1/connect/whatsapp?signup=hosted`). Null for API-key callers and for sessions issued without one.","properties":{"brandName":{"type":["string","null"]},"primaryColor":{"type":["string","null"],"description":"Hex colour, #RRGGBB"},"language":{"type":["string","null"],"enum":["en","es",null]}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/connect/discord":{"post":{"x-resource-group":"accounts","operationId":"connectDiscordChannel","tags":["Connect"],"summary":"Connect a Discord channel","description":"Finalize a Discord connect by binding one channel to a profile. Served by a dedicated route, so it is not reachable through POST /v1/connect/{platform}. One connected account per channel: repeat the call with a different channelId to add another.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["guildId","channelId","profileId"],"properties":{"guildId":{"type":"string","description":"Discord server (guild) the channel belongs to"},"channelId":{"type":"string","description":"Text, announcement or forum channel to publish to"},"profileId":{"type":"string","description":"Profile to connect the channel to"}}}}}},"responses":{"200":{"description":"Channel connected"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"404":{"description":"Profile not found"}}}},"/v1/connect/slack":{"get":{"x-resource-group":"accounts","operationId":"listSlackChannels","tags":["Connect"],"summary":"List Slack channels for the channel picker","description":"Serves the channel picker of the Slack connect flow. Slack's OAuth installs the bot into a\nworkspace, not a channel, so after the redirect the caller lists the workspace's channels\nhere and finalizes one with `POST /v1/connect/slack`. Served by a dedicated route that\nshadows `GET /v1/connect/{platform}` for `slack`.\n\nSend exactly one of `pendingDataToken` (first connect: the nonce from the OAuth redirect,\nbound to the same `profileId`) or `accountId` (add another channel to a workspace already\nconnected: the existing Slack account's workspace token is reused, no re-OAuth). With\nneither, the endpoint behaves like `GET /v1/connect/{platform}` and returns `authUrl` and\n`state` to start the OAuth flow.\n\nChannels are read live from Slack (`conversations.list`, public and private, archived\nexcluded, up to 2,000). `isMember` says whether the Zernio bot is already in the channel:\na public channel is joined automatically on finalize, a private one must be invited\n(`/invite @Zernio`) first.\n","parameters":[{"name":"profileId","in":"query","required":true,"schema":{"type":"string"},"description":"Zernio profile the channel account will belong to. Must match the profile the OAuth flow was started on when `pendingDataToken` is used."},{"name":"pendingDataToken","in":"query","schema":{"type":"string","minLength":16},"description":"Nonce from the OAuth redirect (first connect)."},{"name":"accountId","in":"query","schema":{"type":"string"},"description":"Existing active Slack account (yours or a team member's) whose workspace token is reused."},{"name":"redirect_url","in":"query","schema":{"type":"string","format":"uri"},"description":"Start-OAuth mode only: where to send the user after the connect completes. `redirectUrl` is accepted as an alias."}],"responses":{"200":{"description":"Channel list (picker mode), or the OAuth URL when neither `pendingDataToken` nor `accountId` is sent","content":{"application/json":{"schema":{"oneOf":[{"type":"object","title":"Channel picker","required":["team","channels"],"properties":{"team":{"type":"object","properties":{"id":{"type":"string","description":"Slack workspace (team) id"},"name":{"type":["string","null"]},"icon":{"type":["string","null"],"format":"uri","description":"Workspace icon URL"}}},"channels":{"type":"array","items":{"type":"object","required":["id","name","isPrivate","isMember"],"properties":{"id":{"type":"string","description":"Channel id (C... or G...), the value to send as channelId on POST"},"name":{"type":"string"},"isPrivate":{"type":"boolean"},"isMember":{"type":"boolean","description":"Whether the Zernio bot is already a member of the channel"}}}}}},{"type":"object","title":"Start OAuth","required":["authUrl","state"],"properties":{"authUrl":{"type":"string","format":"uri"},"state":{"type":"string"}}}]},"examples":{"picker":{"value":{"team":{"id":"T0123ABCD","name":"Acme","icon":"https://avatars.slack-edge.com/acme_88.png"},"channels":[{"id":"C0123ABCD","name":"general","isPrivate":false,"isMember":true},{"id":"G0456EFGH","name":"leadership","isPrivate":true,"isMember":false}]}},"startOAuth":{"value":{"authUrl":"https://slack.com/oauth/v2/authorize?client_id=...&state=...","state":"user123-profile456-1234567890-https%3A%2F%2Fzernio.com%2Fdashboard%2Fconnections"}}}}}},"400":{"description":"Invalid profileId or accountId format, or pendingDataToken invalid, expired or issued for another profile (code: invalid_field_value)"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"No access to the profile, or Slack connections are temporarily unavailable (code: feature_not_available)"},"404":{"description":"Profile not found (start-OAuth mode), or no active Slack account with that accountId for this user or their team (code: account_not_found)"}}},"post":{"x-resource-group":"accounts","operationId":"connectSlackChannel","tags":["Connect"],"summary":"Connect a Slack channel","description":"Finalize a Slack connect by creating the per-channel account. Served by a dedicated route, so it is not reachable through POST /v1/connect/{platform}. Send pendingDataToken for a first connect (the nonce from the OAuth redirect) or accountId to add another channel to a workspace already connected.","requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["profileId","channelId"],"properties":{"profileId":{"type":"string"},"channelId":{"type":"string","description":"Slack channel id, C... or G..."},"pendingDataToken":{"type":"string","description":"Nonce from the OAuth redirect. Required unless accountId is sent."},"accountId":{"type":"string","description":"Existing Slack account whose workspace token is reused. Required unless pendingDataToken is sent."}}}}}},"responses":{"200":{"description":"Channel connected"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"$ref":"#/components/responses/PaymentRequired"},"403":{"description":"Slack connections are temporarily unavailable"},"404":{"description":"Profile not found"}}}},"/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.\nStatus values: pending (waiting for user), connected (channel/group linked), expired (generate a new code).\n","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}/webhook-subscription":{"get":{"x-resource-group":"accounts","operationId":"getPageWebhookSubscription","tags":["Connect"],"summary":"Read a Facebook Page's webhook subscription","description":"Returns the webhook fields Zernio's app is subscribed to on the connected Page, read live from Meta.\nUse it to confirm `leadgen` is present: a Page missing it keeps delivering every other event while\nlead ads stop arriving, with nothing to indicate it.\n","parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The Page's current subscription","content":{"application/json":{"schema":{"type":"object","properties":{"pageId":{"type":"string"},"appSubscribed":{"type":"boolean","description":"False when the Page carries no subscription for our app at all."},"leadgen":{"type":"boolean","description":"Whether lead ads submitted on this Page reach Zernio in real time."},"subscribedFields":{"type":"array","items":{"type":"string"}},"warning":{"type":["string","null"],"description":"Present only when leadgen is missing."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The connection has no selected Page"}}},"post":{"x-resource-group":"accounts","operationId":"resyncPageWebhookSubscription","tags":["Connect"],"summary":"Re-subscribe a Facebook Page to Zernio's webhooks","description":"Re-sends the full field set to Meta and returns the subscription read back afterwards.\nMeta only honours the field set sent at subscribe time, so a Page connected before a field\nexisted stays without it until this runs. The response reflects what Meta actually granted,\nnot what was requested.\n","parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"The subscription after re-subscribing","content":{"application/json":{"schema":{"type":"object","properties":{"resubscribed":{"type":"boolean"},"pageId":{"type":"string"},"appSubscribed":{"type":"boolean"},"leadgen":{"type":"boolean"},"subscribedFields":{"type":"array","items":{"type":"string"}},"warning":{"type":["string","null"]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"404":{"$ref":"#/components/responses/NotFound"},"409":{"description":"The connection has no selected Page"},"502":{"description":"Meta rejected the subscription"}}}},"/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.\n"}],"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 a percentage rounded to 2 decimals: (likes + comments + shares + clicks + saves + sends) / impressions * 100. Unlike PostAnalytics.engagementRate on GET /v1/analytics, this one DOES count clicks and has no fallback denominator, so it is 0 whenever impressions is 0. For organization accounts the value is the rate LinkedIn returns, not one computed here."}}},"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"}}}}}},"502":{"$ref":"#/components/responses/UpstreamFailure"},"503":{"$ref":"#/components/responses/TemporarilyUnavailable"}}}},"/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\n(name, headline/job title, profile picture, profile URL, reaction type).\nOnly works for organization/company page accounts. LinkedIn restricts reaction\ndata for personal profiles (r_member_social_feed is a closed permission).\n","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.\n\nHow to use LinkedIn @mentions (2-step workflow):\n\n1. Call this endpoint with the LinkedIn profile/company URL to get the mention URN and format.\n2. Embed the returned mentionFormat (e.g. @[Vincent Jong](urn:li:person:xxx)) directly in your post's content field.\n\nExample:\n- Resolve: GET /v1/accounts/{id}/linkedin-mentions?url=linkedin.com/in/vincentjong&displayName=Vincent Jong\n- Returns: mentionFormat: \"@[Vincent Jong](urn:li:person:xxx)\"\n- Use in post content: \"Great talk with @[Vincent Jong](urn:li:person:xxx) today!\"\n\nImportant: 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.\n\nRequirements:\n- Person mentions require the LinkedIn account to be admin of at least one organization: both endpoints that resolve a profile URL to a member URN take an organization you administer. Organization mentions (e.g. @Microsoft) have no such requirement.\n- The two are not scoped alike. peopleTypeahead?q=organizationFollowers only returns that organization's followers; vanityUrl?q=vanityUrlAsOrganization does not, and its resolvable set is opaque. A 404 here does not mean the person is unknown to you or unknown to LinkedIn: a live public profile can 404 with no reason given.\n- For person mentions to be clickable, the displayName parameter must exactly match the name shown on their LinkedIn profile.\n- Person mentions DO work when published from personal profiles (the URN only needs to be valid). The limitation is only in the resolution step (URL to URN), not in publishing.\n","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, only 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":"LinkedIn couldn't resolve this profile. Not every profile is resolvable through LinkedIn's API, even public ones, and LinkedIn gives no reason. Check the profile address is current (people can change theirs), or mention a company instead."}},"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.\nMeta keeps stories live for 24h; expired stories are not returned.\n\nLimitations propagated from Meta (these are NOT bugs):\n- 24h window only\n- Live videos excluded\n- Reshared stories not returned\n- `mediaUrl` may be null if Meta flagged the story for copyright\n- `caption`, `likeCount`, `commentsCount` do not apply to story media\n","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":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}}},"/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\n24-hour window, so you can pace publishing and warn before the cap is reached.\n\n`quotaUsage` counts containers published since the start of the window.\nAlways compare against the returned `quotaTotal` rather than hardcoding a number:\nMeta's prose documentation and the live API disagree on the value, and the live\nvalue is authoritative.\n","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":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"502":{"description":"Instagram rejected the request"}}}},"/v1/accounts/{accountId}/instagram/audio":{"get":{"x-resource-group":"accounts","operationId":"searchInstagramAudio","tags":["Instagram"],"summary":"Search Instagram audio","description":"Search Instagram's audio catalog (licensed music or original sounds),\nor list what is currently trending by omitting `q`. Returns up to ~30\nassets; Meta exposes no pagination on this edge.\n\nPass the returned `audioId` as\n`platformSpecificData.audioConfiguration.audioId` when creating a Reel\nto publish it with that track.\n\nRequires an Instagram account connected via **Facebook Login**. Meta\nhosts this catalog on graph.facebook.com only, so accounts connected\nwith classic Instagram Login receive a 400\n(`instagram_audio_requires_facebook_login`) and must be reconnected\nchoosing the Facebook option.\n","parameters":[{"name":"accountId","in":"path","required":true,"description":"The ID of the Instagram account","schema":{"type":"string"}},{"name":"audioType","in":"query","required":true,"description":"Catalog to search: licensed music or original sounds from Reels.","schema":{"type":"string","enum":["music","original_sound"]}},{"name":"q","in":"query","required":false,"description":"Search keywords. Omit to get the current trending list.","schema":{"type":"string","minLength":1,"maxLength":200}}],"responses":{"200":{"description":"Matching audio assets (may be empty)","content":{"application/json":{"schema":{"type":"object","properties":{"audio":{"type":"array","items":{"$ref":"#/components/schemas/InstagramAudioAsset"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"502":{"description":"Instagram rejected the request"}}}},"/v1/accounts/{accountId}/instagram/audio/{audioId}":{"get":{"x-resource-group":"accounts","operationId":"getInstagramAudio","tags":["Instagram"],"summary":"Get Instagram audio metadata","description":"Fetch one audio asset's metadata by ID. Use it to re-validate a stored\n`audioId` before a scheduled Reel publishes, or to refresh the preview\n`downloadUrl` (Meta expires preview URLs after roughly 1.5 days).\n\nSame connection requirement as the search endpoint: Facebook-Login\nInstagram accounts only.\n","parameters":[{"name":"accountId","in":"path","required":true,"description":"The ID of the Instagram account","schema":{"type":"string"}},{"name":"audioId","in":"path","required":true,"description":"Instagram audio asset ID","schema":{"type":"string","pattern":"^\\d{1,30}$"}}],"responses":{"200":{"description":"The audio asset","content":{"application/json":{"schema":{"type":"object","properties":{"audio":{"$ref":"#/components/schemas/InstagramAudioAsset"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\nbetween three states:\n\n- `live`: fetched from Meta in real time (story is still active)\n- `cached`: fetched from a persisted `story_insights` webhook payload\n  (story has expired but we received its final-state metrics from Meta)\n- `unavailable`: story has expired and we never received its webhook\n  payload (for example, the account connected after the story expired)\n\nMeta can report an expired story as an empty successful result rather\nthan an error, so an expired story resolves to `cached` or `unavailable`\neven though the upstream request itself succeeded.\n\nField semantics follow Meta's API. Counts below 5 may be returned as 0\ndue to Meta's privacy floor on small audiences. The `navigation` field\nis the sum of `tapsForward + tapsBack + exits + swipesForward`.\n","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":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"502":{"description":"Instagram rejected the request."}}}},"/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-captions":{"get":{"x-resource-group":"accounts","operationId":"getYoutubeCaptions","tags":["Connect"],"summary":"Get a YouTube video transcript","description":"Returns the caption track YouTube already holds for one of the connected channel's own videos, as plain text plus timed cues. Use it instead of downloading and transcribing the video yourself.\n\nAuto-generated (ASR) tracks are included: YouTube serves them to the channel owner, which is what the connected account is. Uploaded tracks win over auto-generated ones when both exist for a language.\n\nCaching: we store the transcript on first read and serve it from there afterwards, so you do not need to cache it yourself. A cached read costs no YouTube quota and does not call YouTube at all. `source` tells you which happened (`youtube` on the first read, `cache` after). Pass `refresh=true` only when the captions actually changed on YouTube, since that re-downloads.\n\nNotes:\n- Only videos owned by this connected channel. Anything else returns 404.\n- `contentDetails.caption` in YouTube's own API reads `false` on videos that DO have a serving auto-generated track, so it is not a usable availability signal. Call this endpoint and handle the 404.\n- YouTube generates auto-captions only for videos with recognisable speech, and can take a few hours after upload to publish them.\n","parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"The connected YouTube account."},{"name":"videoId","in":"query","required":true,"schema":{"type":"string"},"description":"The YouTube video id (the `platformPostId` on a synced external post)."},{"name":"language","in":"query","schema":{"type":"string"},"description":"BCP-47 language tag as YouTube labels the track. `en` also matches an `en-GB` track. Omit to take the best available track."},{"name":"format","in":"query","schema":{"type":"string","enum":["json","srt"],"default":"json"},"description":"`json` returns timed `cues`; `srt` returns the raw SubRip body instead. `text` is present either way."},{"name":"refresh","in":"query","schema":{"type":"boolean","default":false},"description":"Re-download from YouTube instead of serving the stored copy. Spends 200 quota units."}],"responses":{"200":{"description":"The transcript.","content":{"application/json":{"schema":{"type":"object","properties":{"accountId":{"type":"string"},"videoId":{"type":"string"},"language":{"type":"string","description":"The language of the returned track."},"trackId":{"type":"string","description":"YouTube's own caption track id."},"trackKind":{"type":"string","enum":["asr","standard"],"description":"`asr` is YouTube's auto-generated track; `standard` was uploaded by the channel."},"source":{"type":"string","enum":["cache","youtube"],"description":"`cache` when served from our stored copy, `youtube` when this call spent the quota units."},"fetchedAt":{"type":"string","format":"date-time","description":"When the stored copy was downloaded from YouTube."},"text":{"type":"string","description":"The whole transcript as one paragraph, no timings."},"cues":{"type":"array","description":"Timed cues. Present when format is json. Auto-generated cues overlap in time by design (captions roll), so `start` can precede the previous cue's `end`.","items":{"type":"object","properties":{"start":{"type":"number","description":"Seconds from the start of the video."},"end":{"type":"number"},"text":{"type":"string"}}}},"srt":{"type":"string","description":"Raw SubRip body. Present when format is srt."},"availableTracks":{"type":"array","description":"Every track on the video, so you can re-request another language. On a cached read this is the listing as it stood when we downloaded, so a language added to the video since then appears only after a `refresh=true` or when you request that language directly.","items":{"type":"object","properties":{"trackId":{"type":"string"},"language":{"type":"string"},"trackKind":{"type":"string","enum":["asr","standard"]},"name":{"type":"string","description":"The track's display name. Empty for auto-generated tracks."}}}}}},"example":{"accountId":"68a1f2c3d4e5f6a7b8c9d0e1","videoId":"sdw1FML60tM","language":"en","trackId":"AUieDaanCEjmJQdL7wfZbe1QDQTbseX-hToYw99pmOJitlJz868","trackKind":"asr","source":"cache","fetchedAt":"2026-08-27T21:09:54.000Z","text":"Hey, this is Mickey. I'm the founder of this portfolio of three websites which are mainly monetized via AdSense.","cues":[{"start":1.6,"end":8.88,"text":"Hey, this is Mickey. I'm the founder of"},{"start":5.04,"end":12.8,"text":"this portfolio of three websites"}],"availableTracks":[{"trackId":"AUieDaanCEjmJQdL7wfZbe1QDQTbseX-hToYw99pmOJitlJz868","language":"en","trackKind":"asr","name":""}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Account not found, the video does not belong to this channel (`video_not_found`), or the video has no caption track in the requested language (`captions_not_found`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"This video has no caption tracks. YouTube generates them only for videos with recognisable speech, and it can take a few hours after upload.","type":"not_found","code":"captions_not_found","param":"videoId"}}}}}}},"/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"}}},"post":{"operationId":"createYoutubePlaylist","x-resource-group":"accounts","tags":["Connect"],"summary":"Create YouTube playlist","description":"Creates an empty playlist on the connected YouTube channel. Requires a title; privacy defaults to private. Returns the same playlist shape as the list endpoint. Pass the returned playlist.id as platformSpecificData.playlistId when publishing a video. Does not change the account's default playlist. Requires the youtube or youtube.force-ssl OAuth scope. Costs 50 YouTube quota units. This operation is not idempotent and is not automatically retried: repeating a request can create another playlist, including after a timeout. List playlists before retrying an ambiguous failure. Official series settings are not exposed by YouTube's public API and must be enabled manually in YouTube's desktop playlist settings.\n","parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["title"],"properties":{"title":{"type":"string","minLength":1,"description":"Playlist title. Leading and trailing whitespace is removed."},"description":{"type":"string","description":"Optional playlist description."},"privacy":{"type":"string","enum":["private","public","unlisted"],"default":"private"}}},"example":{"title":"Tutorials","description":"Step-by-step video tutorials","privacy":"private"}}}},"responses":{"201":{"description":"Playlist created","content":{"application/json":{"schema":{"type":"object","required":["playlist"],"properties":{"playlist":{"type":"object","required":["id","title","description","privacy","itemCount","thumbnailUrl"],"properties":{"id":{"type":"string"},"title":{"type":"string"},"description":{"type":"string"},"privacy":{"type":"string","enum":["private","public","unlisted"]},"itemCount":{"type":"integer"},"thumbnailUrl":{"type":"string"}}}}},"example":{"playlist":{"id":"PLxxxxxxxxxxxxx","title":"Tutorials","description":"Step-by-step video tutorials","privacy":"private","itemCount":0,"thumbnailUrl":""}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"API key lacks access to the account or YouTube denied permission."},"404":{"description":"Account not found or inaccessible."},"429":{"description":"YouTube rate limit exceeded."},"502":{"description":"YouTube request failed. Check existing playlists before retrying, because the playlist may have been created."}}},"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 Google Business Profile 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.\n","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 Google Business Profile location","description":"Switch which Google Business Profile 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 Profile 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.\n"}}},"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 Google Business Profile location to another profile","description":"Connect a Google Business Profile location onto a DIFFERENT profile by reusing the OAuth grant from an already-connected Google Business Profile account, with no browser and 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 Google Business Profile account (the token holder); the body `profileId` is the TARGET profile. Returns 409 if the target profile already has a Google Business Profile connection (switch its location with PUT gmb-locations instead).\n","parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"A source connected Google Business Profile 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 Profile location ID to assign (e.g. \"locations/123\")."},"googleAccountId":{"type":"string","description":"Optional but recommended. The Google Business Profile 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.\n"}}},"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 Google Business Profile location id (digits only). Combine with the Google Business Profile 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 Profile account not found"},"409":{"description":"Target profile already has a Google Business Profile 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\nplus the overall total.\n\nThe whole breakdown is fetched in a single Graph call. The post analytics\nendpoint reports only an aggregate reaction count (surfaced there as `likes`), so use\nthis endpoint when you need per-type counts.\n","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\nthem before submitting and avoid a removal.\n\nUse this alongside `POST /v1/tools/validate/subreddit`, which only confirms that a\nsubreddit exists and reports its basic posting settings.\n","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.\n\n**Reddit requires that votes be cast by humans.** Reddit's API terms permit a client\nto proxy a human's action one-for-one, and prohibit a bot from deciding how to vote\nor from amplifying a human's vote. Call this endpoint only in direct response to an\nexplicit action by the account owner. Automated or agent-decided voting is\nvote manipulation and puts API access at risk.\n","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_\").\n","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\npath to list the available `flairTemplateId` values for the subreddit.\n\nFlair can also be set at submit time by passing `flairId` in `platformSpecificData`\nwhen creating the post. This endpoint is for changing it afterwards.\n\nThe subreddit must allow users to select their own post flair. Setting flair on\nanother user's post requires moderator permissions, which Zernio does not request.\n","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}/bluesky-settings":{"get":{"x-resource-group":"accounts","operationId":"getBlueskySettings","tags":["Accounts"],"summary":"Get Bluesky account settings","description":"Returns the account's default post languages (defaultLangs), applied at publish time whenever a post's platformSpecificData.langs is absent. Null when no default is set.","parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Bluesky account settings","content":{"application/json":{"schema":{"type":"object","properties":{"defaultLangs":{"type":["array","null"],"items":{"type":"string"},"example":["pt"]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Account not found"}}},"patch":{"x-resource-group":"accounts","operationId":"updateBlueskySettings","tags":["Accounts"],"summary":"Update Bluesky account settings","description":"Set or clear the account's default post languages. 1-3 BCP-47 codes (e.g. \"pt\", \"en-US\"), the same validation as per-post langs; explicit null clears the default. Per-post platformSpecificData.langs always overrides this default. Applies to posts published after the change; already-published posts cannot be retagged (Bluesky has no post edit).","parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["defaultLangs"],"properties":{"defaultLangs":{"type":["array","null"],"minItems":1,"maxItems":3,"items":{"type":"string"},"example":["pt"]}}}}}},"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):\n\n1. **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.\n\n2. **Switch channel** - Move the connection to a different channel in the same guild. A new webhook is automatically created in the target channel.\n","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).\nSupports the same payload shape as channel posts: content, embeds, media\nattachments, and TTS.\n\nConstraints (Discord platform limits):\n  - The bot can only DM users it shares at least one guild with.\n  - If the recipient has DMs disabled for non-friends, Discord returns 403\n    (surfaces as a 502 platform error).\n  - `content` capped at 2,000 chars.\n  - At least one of `content`, `embeds`, or `attachments` is required.\n  - The recipient must be identified by Discord snowflake ID (not username).\n\nThis is a dedicated endpoint rather than a `POST /v1/posts` variant because\nDMs are 1:1 operational messages (onboarding, billing reminders, support\npings) with a different lifecycle than scheduled channel posts. DMs are\nnot persisted to `Post` / `ExternalPost` and are always sent immediately.\n","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, using 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"},"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."},"404":{"description":"Discord account not found or not accessible to this user."},"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\npickers, role-permission UIs, or finding the role ID before calling\nthe role-assign endpoint.\n\nRoles are returned unordered. Sort client-side by `position` if you\nneed Discord's UI ordering.\n\nCaller must pass `accountId` of a Discord SocialAccount bound to this\nguild (route verifies team access + guild match).\n","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"},"403":{"description":"Discord refused the request (bot lacks View Channels permission in the guild)."},"404":{"description":"Discord account not found, not accessible, or not bound to this 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.\n\nRequires the bot to hold the Manage Roles permission. Guilds that added the Zernio bot\nbefore role management shipped must re-invite it, because Discord applies the\npermission set at invite time.\n\nDiscord's role hierarchy applies: the bot cannot create a role positioned at or above\nits own highest role, and cannot grant permissions it does not itself hold. Either\nattempt returns a 403 carrying Discord's own error.\n","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"},"403":{"description":"Discord refused the action (bot lacks Manage Roles, or the new role would sit at or above the bot's highest role)."},"404":{"description":"Discord account not found, not accessible, or not bound to this guild."},"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.\nAt least one field must be supplied. Omitted fields are left unchanged.\n\nRequires the bot to hold Manage Roles, and the target role must sit below the bot's\nhighest role. See the create-role operation for the re-invite requirement.\n","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"},"403":{"description":"Discord refused the action (bot lacks Manage Roles, or the target role sits at or above the bot's highest role)."},"404":{"description":"Discord account not found, not accessible, or not bound to this guild."},"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.\nThis cannot be undone.\n\nRequires the bot to hold Manage Roles, and the target role must sit below the bot's\nhighest role.\n","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"},"403":{"description":"Discord refused the action (bot lacks Manage Roles, or the target role sits at or above the bot's highest role)."},"404":{"description":"Discord account not found, not accessible, or not bound to this guild."},"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\nobjects so callers can build community-ops automation (e.g. \"add role\nto all members joined in the last 7 days\") on the actual platform shape.\n\nPagination: pass `after` = the last `user.id` from the previous page.\nOmit on the first call. Response includes a `nextCursor` and `hasMore`\nflag so callers don't need to know Discord's pagination shape.\n","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\nquery (Discord matches prefixes only, not substrings).\n\nCheaper than paginating the full member listing when you already know\nwho you are looking for.\n","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.\n\nCheaper than paginating the full member listing when you already know\nwho you are looking for.\n","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\non a member who already has the role is a 204 no-op.\n\nPath shape mirrors Discord's own API (`PUT /guilds/{guild}/members/{user}/roles/{role}`)\nfor zero-translation mental mapping.\n\nBot needs MANAGE_ROLES permission in the guild AND its highest role\nmust be above the target role (Discord hierarchy rule). The\n`@everyone` role (where roleId == guildId) cannot be assigned.\n","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"},"403":{"description":"Discord refused the request: bot lacks MANAGE_ROLES, or target role is at or above the bot's highest role."},"404":{"description":"Discord account not found or not in this guild."},"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\nmember doesn't have returns 204 no-op.\n\nSame permission + hierarchy constraints as the PUT counterpart.\n","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"},"403":{"description":"Discord refused the request (permission or hierarchy issue)."},"404":{"description":"Discord account not found or not in this guild."},"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.\n\nDeleting a message the bot did not send requires the bot to hold the Manage Messages\npermission, which the Zernio bot requests at install time. Deleting the bot's own\nmessage needs no extra permission.\n\nOwnership is verified by resolving the channel's guild and confirming the caller owns\na Discord account bound to it.\n","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"},"403":{"description":"Discord refused the action (bot lacks Manage Messages)."},"404":{"description":"Discord account not found, not accessible, or not bound to this channel's guild."},"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\nfollowing that channel.\n\nThe source channel must be an announcement channel. Calling this on a regular text\nchannel returns a 400 before Discord is contacted, because Discord's own error for\nthis case is opaque.\n","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"},"403":{"description":"Discord refused the action (bot lacks the required permission)."},"404":{"description":"Discord account not found, not accessible, or not bound to this channel's guild."},"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\nexisting message, or omit it to create a standalone thread.\n\nThreads created here are always public. Requires the bot to hold Create Public\nThreads, which the Zernio bot requests at install time.\n","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"},"403":{"description":"Discord refused the action (bot lacks Create Public Threads)."},"404":{"description":"Discord account not found, not accessible, or not bound to this channel's guild."},"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\nfirst. Discord caps a channel at 50 pinned messages and returns the\nfull list unpaginated.\n\nBot needs READ_MESSAGE_HISTORY in the channel (granted by default\nBOT_PERMISSIONS).\n","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\nAPI (`PUT /channels/{cid}/pins/{mid}`).\n\nIdempotent: re-pinning an already-pinned message is a 204 no-op.\n\nConstraints:\n  - Bot needs MANAGE_MESSAGES in the channel.\n  - 50-pin cap per channel: hitting it returns 400 (Discord-side).\n    Caller should unpin one first.\n","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.\nIdempotent: unpinning a non-pinned message is a 204 no-op.\n","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\nmessages: they appear in the server's Events panel and Discord\nauto-notifies interested members ahead of start time.\n\nPass `withUserCount=true` to include `user_count` (number of members\nwho RSVP'd) on each event. Useful for surfacing engagement.\n","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\ndiscriminator on `entity.type`:\n\n  - `external`: off-platform (Zoom, in-person, livestream). Requires\n    both `location` and `endsAt`. Most common type for scheduler\n    integrations.\n  - `voice`: hosted in a Discord voice channel. Requires `channelId`.\n  - `stage`: hosted in a Discord stage channel. Requires `channelId`.\n\nBot needs MANAGE_EVENTS in the guild. Existing installs (pre-events\nPR) need a re-invite OR a server admin manually granting the\npermission. See route header for details.\n","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\ncancel an event. Discord doesn't have a dedicated cancel endpoint,\nit's a status transition.\n\nMost status transitions Discord enforces (you can't go SCHEDULED →\nCOMPLETED directly). The common consumer case is SCHEDULED → CANCELED.\n","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\nif you want the event preserved in the guild's history.\n","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.\nSubsequent queues are non-default unless explicitly set.\n","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.\n","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;\nomit it to delete all queues for the profile.\nIf deleting the default queue, another queue will be promoted to default.\n","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"}}}},"/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.\n\n`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.\n\nWebhooks are auto-disabled only once the endpoint has had no successful delivery for 3 days AND has either reached 20 consecutive terminal failures (each one an event that exhausted the full retry ladder) or been failing continuously for 3 days. The owner is emailed; re-enable it with `isActive: true`.\n\nA restricted (zrk_) API key can only subscribe to events whose resource group\nthe key holds; an event outside the key's groups is rejected with 403, so a\nrestricted key can never create a subscription broader than itself.\n\n`disabledResourceGroups` restricts the subscription itself, independently of\nwhich key or session later reads it. Events in a disabled group are dropped\nbefore delivery to this endpoint, on live delivery and on every replay path\n(test fire, redelivery, dead-letter requeue), even if they are listed in\n`events`. Omit it to receive everything in `events`, which is how existing\nsubscriptions behave. A restricted key's own disabled groups are always\nunioned in.\n","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","analytics.synced","message.received","conversation.started","conversation.control_changed","call.received","call.ended","call.failed","call.permission_request","message.sent","message.edited","message.deleted","message.delivered","message.read","message.failed","reaction.received","referral.received","comment.received","review.new","review.updated","lead.received","ad.status_changed","whatsapp.template.status_updated","whatsapp.template.category_updated","whatsapp.account.name_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","phone_number.stock_available","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 direct 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.\n\nWhen 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.\n\nWebhooks are auto-disabled only once the endpoint has had no successful delivery for 3 days AND has either reached 20 consecutive terminal failures (each one an event that exhausted the full retry ladder) or been failing continuously for 3 days. The owner is emailed; re-enable it with `isActive: true`.\n\nA restricted (zrk_) API key can only set `events` to events whose resource\ngroup the key holds; an event outside the key's groups is rejected with 403.\nIt also cannot widen an existing subscription past its own groups.\n\n`disabledResourceGroups` replaces the subscription's own denylist, which\napplies to delivery regardless of which key or session created it. Send an\nempty array to clear it. A restricted key's own disabled groups are unioned\ninto the stored value on every update, so repointing a legacy unrestricted\nsubscription with a restricted key also narrows it.\n\nTiming: the new denylist applies to every event emitted after the update.\nEvents already queued for delivery when the update landed were filtered\nagainst the previous denylist and can still arrive at your endpoint for up\nto five minutes after they were enqueued, because the delivery worker\ntrusts a five-minute enqueue-time snapshot before re-checking the\nsubscription. Retries beyond that window, dead-letter replays, test fires,\nand redeliveries are all checked against the current denylist.\n","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","analytics.synced","message.received","conversation.started","conversation.control_changed","call.received","call.ended","call.failed","call.permission_request","message.sent","message.edited","message.deleted","message.delivered","message.read","message.failed","reaction.received","referral.received","comment.received","review.new","review.updated","lead.received","ad.status_changed","whatsapp.template.status_updated","whatsapp.template.category_updated","whatsapp.account.name_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","phone_number.stock_available","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","pattern":"^[a-fA-F0-9]{24}$"}}],"responses":{"200":{"description":"Webhook deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"400":{"description":"Webhook ID missing or not a valid ID"},"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.\nLogs are retained for 30 days. Supports filtering by status, event type, webhook ID, and event ID,\nplus offset-based pagination.\n\nFor a restricted (zrk_) API key, rows for events outside the key's resource\ngroups are omitted (`pagination.total` may over-count), and an `event` filter\nnaming such an event is rejected with 403. Events blocked by a subscription's\nown `disabledResourceGroups` are dropped before delivery, so they produce no\nlog rows for anyone; the exception is the five-minute tail after a denylist\nchange, where an already-queued event can still be delivered and logged.\n","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/logs/redeliver":{"post":{"x-resource-group":"webhooks","operationId":"redeliverWebhookEvent","tags":["Webhooks"],"summary":"Redeliver a webhook event","description":"Replay a past delivery: the original payload is re-sent, byte for byte, to the\nsubscription's current URL. The original event ID is preserved so your endpoint can\ndedupe, and the replay is recorded as a fresh attempt, so it shows up in\n`GET /v1/webhooks/logs` next to the delivery it replays.\n\nBoth `webhookId` and `eventId` come from a row of `GET /v1/webhooks/logs`. Because\nthe stored payload is replayed as-is, a redelivery reflects the event as it was\nemitted, not the current state of the resource.\n\nOnly deliveries inside the 30-day log retention window can be replayed; past that\nthe payload is gone and the request fails with a 422. Replays run the same\nresource-group checks as live delivery, against both the key's groups and the\nsubscription's `disabledResourceGroups`.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["webhookId","eventId"],"properties":{"webhookId":{"type":"string","minLength":1,"description":"ID of the webhook subscription that delivered the event"},"eventId":{"type":"string","minLength":1,"description":"Stable event ID of the delivery to replay"}}},"example":{"webhookId":"507f1f77bcf86cd799439011","eventId":"evt_9f2c1b7a4d8e"}}}},"responses":{"200":{"description":"Event re-delivered successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"message":{"type":"string"}}},"example":{"success":true,"message":"Webhook re-delivered"}}}},"400":{"description":"webhookId or eventId missing or empty, or the subscription has no URL configured"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ResourceGroupForbidden"},"404":{"description":"Webhook subscription not found"},"422":{"description":"Original payload not replayable: no delivery matches this event inside the 30-day retention window, or the stored payload is truncated or not valid JSON"},"502":{"description":"Re-delivery was attempted but your endpoint errored again. The attempt is still\nlogged; `message` describes the failure.\n","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"message":{"type":"string"}}}}}}}}},"/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.\n\n`webhook.test` belongs to the `webhooks` resource group, so a key with that\ngroup disabled is rejected with 403, as is a test fire on a subscription that\nlists `webhooks` in its own `disabledResourceGroups` (a 403, not a reported\ndelivery failure). Replays of real events (redelivery, dead-letter requeue) run\nthe same checks as live delivery, against both the key's groups and the\nsubscription's.\n","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.\nFilter by type, platform, status, and time range. Logs are retained for 90 days.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"type","in":"query","description":"Log category to query. Use `all` for the unified view across every category,\nor `api_request` for your API request logs (method, path, status, latency).\n","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"}}}},"/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.\n\nSupported platforms: Facebook, Instagram, X, Bluesky, Reddit, Telegram.\n\n**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 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.\n\n**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.\n\n- 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.\n- Replayed history emits no webhooks and is stored as already read, so it never affects unread counts.\n- 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.\n","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 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 verified badge type. Only present for 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"},"threadControl":{"type":"string","enum":["app","ai_agent","other"],"description":"WhatsApp only, present once Meta Business Agent has touched the thread. ai_agent: the agent answers and new inbound arrive flagged metadata.standby; app: you hold control; other: another partner app does. Change it with POST /v1/inbox/conversations/{conversationId}/thread-control."},"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":"Click attribution for a conversation that started from a Meta ad or\na ref-tagged ig.me / m.me link. Absent when the conversation did not\noriginate from an attributable click.\n\nCaptured from the referral Meta delivers for the click. If the same\nperson later arrives through a different ad or link, the original\nvalues are kept, so the first referral wins; read the fresh referral\nper click on the `message.received` / `referral.received` webhooks\ninstead. One exception on WhatsApp: when Meta omits `ctwa_clid`\nfrom that referral, a later Meta automatic event can supply it and\nrefresh `ctwa_captured_at`, so treat `ctwa_captured_at` as the time\nZernio stored the value, not the time of the click.\n\nTwo families of keys, one per surface. They never appear together:\n\n  - `ctwa_*` is WhatsApp Click-to-WhatsApp. The ad ID is\n    `ctwa_source_id`. There is no `meta_ad_id` on WhatsApp.\n  - `meta_ad_*` is Instagram Click-to-Direct, Facebook Messenger\n    Click-to-Message, and ig.me / m.me ref links. The ad ID is\n    `meta_ad_id` (ad clicks only; a link capture carries\n    `meta_ad_ref` without it). `ctwa_clid` never appears on these\n    platforms.\n\nEvery key is optional and only the keys Meta supplied are returned, so\nread defensively. Meta does not send a campaign or ad set ID, so none\nis exposed here. More keys may be added over time. Treat any key you\ndo not recognise as an opaque string.\n\nKey names differ from the `message.received` webhook on purpose. The\nwebhook forwards Meta's referral verbatim (`ad_id`, `source`, `type`)\nwhile the stored conversation record uses the prefixed names below.\nRenaming either side would break existing integrations, so both\nspellings are kept.\n","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. Present for ad clicks; absent when the capture came from an ig.me / m.me ref link."},"meta_ad_source":{"type":"string","description":"Instagram and Facebook only. Meta-supplied source identifier: ADS for ad clicks; SHORTLINK, SHORTLINKS or IGME-SOURCE-LINK for ref links (treat as opaque)."},"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 or the ig.me / m.me link."},"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"},"accountsSkipped":{"type":"array","description":"Connected accounts that were not queried: their platform does not support this feature, or the account is not enabled for it","items":{"type":"object","properties":{"accountId":{"type":"string"},"platform":{"type":"string"}}}}}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Inbox addon required"}}},"post":{"x-resource-group":"messages","operationId":"createInboxConversation","summary":"Create conversation","description":"Start a direct message conversation with a user. If a conversation with that recipient already exists, the message is added to the existing thread.\n\nSupported platforms: X, Bluesky, Reddit, WhatsApp, SMS, and Slack. Other platforms return PLATFORM_NOT_SUPPORTED.\n\n**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.\n\n**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.\n\nBefore sending, Zernio must resolve an exact APPROVED template definition matching both templateName and templateLanguage. If Meta rejects that lookup, or the exact approved definition is absent, Zernio sends no message and returns the canonical platform error instead. Lookup errors use code `platform_api_error`, type `platform_error`, and platform `whatsapp`. Sanitized Meta code, message, and `error_data.details` are returned in `platformError`; `details` identifies `phase: template_lookup`, the query-free endpoint, upstream status, and only safe provider usage or retry headers.\n\n- 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.\n- A template whose approved header format is LOCATION has no header asset to reconstruct at all: Meta only accepts the location at send time, so pass headerLocation (latitude and longitude required) whenever such a template is sent; headerMedia and headerLocation cannot both be supplied.\n- A button that carries its own value at send time (a copy-code button holding a Pix payment code or a coupon, a flow token) is sent with templateButtonParams, addressed by the button's index; templateParams covers text variables and dynamic URL buttons only.\n- CAROUSEL templates take per-card overrides via templateCards, each addressed by the card's card_index, because card body variables restart at {{1}} per card and cannot be expressed in the flat templateParams order.\n- Template fields are accepted on the JSON body only, not on multipart requests.\n\nFor a number you already have a thread with, this 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).\n\nAlternatively, 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.\n\n**DM eligibility (X).** 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.\n\n**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.\n\n**Rate limits (X 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.\n","tags":["Messages"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId"],"properties":{"accountId":{"type":"string","description":"The 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 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."},"linkPreview":{"type":"boolean","default":true,"description":"WhatsApp only. Set false to send the Direct Send (category: 'utility') text message without a link-preview thumbnail for the first URL in the text. Defaults to true, which is how every WhatsApp text has been sent to date. Does not apply to template sends. 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]. For positional templates the list must cover every slot: supplying fewer values than the template's header + body + dynamic URL-button count is rejected with a 400 (code INVALID_TEMPLATE_PARAMS) naming the expected split, rather than delivering a template whose button URL was filled from the wrong value. A dynamic URL button covered by templateButtonParams needs no value here unless another uncovered dynamic URL button follows it, since the override applies after slot numbering. 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). Buttons that are not dynamic-URL buttons (copy-code, flow) take no value here either; use templateButtonParams."},"templateButtonParams":{"type":"array","maxItems":10,"description":"WhatsApp only. Values for template buttons that carry one at send time, each addressed by the button's position in the approved template. This is the only way to send a copy-code button's payload (a Pix payment code, a coupon) or a flow token, because templateParams is a flat array of text variables and covers dynamic URL buttons only. Supplying a button here overrides whatever templateParams would have derived for that same index, so the send never carries one button twice; repeating an index within this array is rejected with 400. Each index must name a button of the matching kind on the approved template, which is also checked before the send and returns 400 (INVALID_TEMPLATE_BUTTON_PARAM) rather than a Meta rejection.","items":{"type":"object","required":["index","subType","value"],"properties":{"index":{"type":"integer","minimum":0,"maximum":9,"description":"Zero-based position of the button in the approved template's buttons."},"subType":{"type":"string","enum":["url","copy_code","flow"],"description":"The button kind, which decides how the value is sent: copy_code sends it as the coupon_code payload, flow as the flow token, url as the dynamic suffix appended to the button's base URL."},"value":{"type":"string","minLength":1,"description":"The value to send (e.g. the Pix copy-and-paste code for a copy_code button)."}}}},"templateCards":{"type":"array","maxItems":10,"description":"WhatsApp only. Per-card overrides for a CAROUSEL template, each addressed by the card's card_index. Carousel card body variables restart at {{1}} per card, so they cannot be expressed in the flat templateParams slot order; use this instead. A cardIndex naming a card the approved template does not have, a duplicate cardIndex, or a params count that does not match the card body's token count is rejected with 400 (INVALID_TEMPLATE_CARD_PARAM).","items":{"type":"object","required":["cardIndex"],"properties":{"cardIndex":{"type":"integer","minimum":0,"maximum":9,"description":"The card's card_index in the approved template."},"params":{"type":"array","items":{"type":"string"},"description":"Values for this card's own body variables, in the card's own {{1}}, {{2}}, ... order (or named-slot order of appearance)."},"headerMedia":{"type":"object","description":"Overrides this card's header asset for THIS send. Without it, the card's approved sample asset is sent.","required":["type"],"properties":{"type":{"type":"string","enum":["image","video","document"],"description":"Must match the card 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."}}},"buttons":{"type":"array","maxItems":2,"description":"Values for this card's own buttons, each addressed by the button's index within the card.","items":{"type":"object","required":["index","subType","value"],"properties":{"index":{"type":"integer","minimum":0,"maximum":1,"description":"Zero-based position of the button within the card's buttons."},"subType":{"type":"string","enum":["quick_reply","url"],"description":"The button kind, which decides how the value is sent."},"value":{"type":"string","minLength":1,"description":"The value to send (quick_reply payload, or the URL dynamic suffix)."}}}}}}},"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."}}},"headerLocation":{"type":"object","description":"WhatsApp only. Required to send a template whose approved header format is LOCATION: Meta only accepts the location's lat/long at send time, never at template creation, so there is nothing to fill in automatically. Cannot be combined with headerMedia (a template has exactly one header).","required":["latitude","longitude"],"properties":{"latitude":{"type":"number","description":"Latitude in decimal degrees."},"longitude":{"type":"number","description":"Longitude in decimal degrees."},"name":{"type":"string","description":"Location name shown to the recipient (e.g. a business name)."},"address":{"type":"string","description":"Location address shown to the recipient."}}}}}},"multipart/form-data":{"schema":{"type":"object","required":["accountId"],"properties":{"accountId":{"type":"string","description":"The account ID to send from"},"participantId":{"type":"string","description":"X numeric user ID of the recipient"},"participantUsername":{"type":"string","description":"X 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":"X numeric user ID of the recipient"},"participantName":{"type":["string","null"],"description":"Display name of the recipient"},"participantUsername":{"type":["string","null"],"description":"X username of the recipient"}}}}}}}},"400":{"description":"Validation error, platform not supported, an attachment the platform does not accept (PLATFORM_LIMITATION), template required to start a WhatsApp conversation (TEMPLATE_REQUIRED), template variables that do not match the approved definition (INVALID_TEMPLATE_PARAMS, INVALID_TEMPLATE_BUTTON_PARAM), a missing or incompatible required template header (INVALID_TEMPLATE_HEADER), templateCards that do not match the approved carousel definition (INVALID_TEMPLATE_CARD_PARAM), category combined with templateName or used on a non-WhatsApp account, or the WhatsApp Business Account is not eligible for Direct Send: DIRECT_SEND_NOT_ELIGIBLE and DIRECT_SEND_BLOCKED require Meta to grant or restore Direct Send access, while DIRECT_SEND_LIMITED is temporary and lifts on its own","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"string"},"code":{"type":"string","enum":["PLATFORM_NOT_SUPPORTED","PLATFORM_LIMITATION","TEMPLATE_REQUIRED","INVALID_TEMPLATE_PARAMS","INVALID_TEMPLATE_BUTTON_PARAM","INVALID_TEMPLATE_HEADER","INVALID_TEMPLATE_CARD_PARAM","DIRECT_SEND_NOT_ELIGIBLE","DIRECT_SEND_LIMITED","DIRECT_SEND_BLOCKED"]}}},{"$ref":"#/components/schemas/WhatsAppTemplateLookupError"}]}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Inbox addon required or profile limit reached"},"404":{"description":"Account or recipient user not found (Reddit: PARTICIPANT_NOT_FOUND when the u/username does not exist)","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"string"},"code":{"type":"string","enum":["account_not_found","PARTICIPANT_NOT_FOUND"]}}},{"$ref":"#/components/schemas/WhatsAppTemplateLookupError"}]}}}},"422":{"description":"Recipient does not accept DMs from this account (X), or does not accept direct messages from you (Reddit)","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"string"},"code":{"type":"string","enum":["DM_NOT_ALLOWED"]}}},{"$ref":"#/components/schemas/WhatsAppTemplateLookupError"}]}}}},"429":{"description":"X API rate limit exceeded, Reddit rate limit reached, or Meta rejected the WhatsApp template-definition lookup. A lookup failure sends no message.","content":{"application/json":{"schema":{"anyOf":[{"type":"object","properties":{"error":{"type":"string"},"code":{"type":"string","enum":["rate_limited"]}}},{"$ref":"#/components/schemas/WhatsAppTemplateLookupError"}]}}}},"502":{"description":"The exact approved WhatsApp template definition is unavailable. No message was sent.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WhatsAppTemplateLookupError"}}}},"default":{"description":"Meta rejected the WhatsApp template-definition lookup with another upstream status. No message was sent.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WhatsAppTemplateLookupError"}}}}}}},"/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:\n\n- 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.\n- 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.\n\nA conversation that matches both ways is returned once, carrying its message matches.\n\nOnly platforms whose messages are stored by Zernio are searchable: WhatsApp, SMS, Telegram, Facebook, Instagram, X and Reddit. Bluesky conversations are fetched live from the platform and cannot be searched; those accounts are listed in meta.accountsSkipped.\n","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 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":"Opaque pagination cursor. Pass back pagination.nextCursor verbatim; do not construct one."}],"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, unsupported platform, or malformed cursor"},"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":"Opaque conversation identifier, accepted verbatim from the list endpoint or from the conversationId on inbox webhooks. Format not to be assumed."},{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"The 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 verified badge type. Only present for 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.\nAbsent when the conversation did not originate from an ad click.\n\nCaptured once, on the first inbound message after the click, and never\noverwritten. If the same person later clicks a different ad, the\noriginal values are kept. Meta only sends the referral on that first\nmessage.\n\nThis operation currently returns only the `meta_ad_*` family, which\ncovers Instagram Click-to-Direct and Facebook Messenger\nClick-to-Message. WhatsApp Click-to-WhatsApp attribution (the `ctwa_*`\nkeys, where the ad ID is `ctwa_source_id`) is returned by\n`GET /v1/inbox/conversations` instead.\n\nEvery key is optional and only the keys Meta supplied are returned, so\nread defensively. Meta does not send a campaign or ad set ID, so none is\nexposed here. More keys may be added over time. Treat any key you do not\nrecognise as an opaque string.\n\nKey names differ from the `message.received` webhook on purpose. The\nwebhook forwards Meta's referral verbatim (`ad_id`, `source`, `type`)\nwhile the stored conversation record uses the prefixed names below.\nRenaming either side would break existing integrations, so both\nspellings are kept.\n","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":"Opaque conversation identifier, accepted verbatim from the list endpoint or from the conversationId on inbox webhooks. Format not to be assumed."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","status"],"properties":{"accountId":{"type":"string","description":"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\nand ordering control.\n\nPagination: pass `pagination.nextCursor` from a prior response back as\nthe `cursor` query param to fetch the next page. The cursor is opaque;\ndo not parse or construct it client-side.\n\nSort order: defaults to `asc` (oldest first, chat style). For the\n\"show me the latest messages\" pattern, pass `?sortOrder=desc&limit=N`.\nX, Instagram, Telegram, WhatsApp and Reddit honor the requested\norder from the local message store. For Facebook and Bluesky, the\nupstream APIs only return newest-first and have no order parameter, so\nsort order is best-effort and only reverses items within a single page\n(pages still walk newest→oldest). The response field `sortOrderApplied`\ntells you what was actually applied.\n\nReddit threads are paginated client-side because Reddit's API has no\nper-thread cursor. Very long threads may be upstream-truncated by\nReddit's inbox/sent windows (~100 most-recent items each); this is a\nReddit platform limitation.\n\nInstagram and Facebook conversations include history from before the\naccount was connected, replayed from Meta. That replay covers the 500\nmost recent messages per conversation: a longer thread keeps its newest\n500 and older messages are not retrievable. Messages that arrived after\nthe account was connected are unaffected. Replayed messages are stored\nas already read and emit no webhooks.\n\nX 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.\n\nThis endpoint is read-only and does NOT mark messages as read or send\nread receipts. To mark a conversation read (and send WhatsApp blue ticks\non eligible accounts), call `POST /v1/inbox/conversations/{conversationId}/read`.\n","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."},{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"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 verbatim: a cursor we cannot parse returns 400 rather than silently restarting from the first page."},{"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).\nX, Instagram, Telegram, WhatsApp and Reddit honor this order\nacross cursor pages. For Facebook and Bluesky, only intra-page\nordering is affected. Pages always walk newest→oldest. See\n`sortOrderApplied` in the response.\n"}],"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\ndiffer from the requested `sortOrder` for Facebook and\nBluesky (always `desc` regardless of request).\n"},"messages":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"The platform's own message id: the `wamid` on WhatsApp, the\n`mid` on Instagram and Facebook Messenger. This is what\n`metadata.quotedMessageId` points at, the value to pass as\n`replyTo` on the platforms that support quote-replies, and the\n`{messageId}` segment of the attachment-resolve URL. Webhooks\ndeliver the same value as `message.platformMessageId`; this\nresponse has no field by that name.\n"},"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 verified badge type. Only present for 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"]},"originalType":{"type":"string","description":"Instagram and Facebook only, and present only when it differs from `type`. Meta's own type before normalization: `ig_reel` and `reel` become `video`, while `ig_post`, `post`, `ig_story` and `story_mention` become `share`. A story mention is `type: \"share\"` with `originalType: \"story_mention\"`; render on this field, since `share` alone is ambiguous."},"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"},"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"},"details":{"type":"string","description":"Platform's extended detail for `code` (WhatsApp: Meta's `error_data.details`), when the platform sent one. Absent on SMS."},"href":{"type":"string","format":"uri","description":"Link to the platform's documentation for `code`, when the platform sent one."}}},"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:\n`quotedMessageId` (the `id` of the message this one replies to,\ndelivered as `message.platformMessageId` on webhooks),\n`waInteractive` (a compact descriptor of WhatsApp interactive\ncontent sent: buttons / list / cta_url / flow / location_request),\nand for inbound interactive taps `interactiveType` / `interactiveId`.\nIt can also carry `source` (`whatsapp_business_app` /\n`coexistence_history` on a WhatsApp Coexistence number, `bulk-api` on\na POST /v1/whatsapp/bulk send), which is where the message reached us\nfrom rather than who produced it: read `sentVia` for that.\n","additionalProperties":true},"sentVia":{"type":["string","null"],"enum":["human","api","broadcast","sequence","workflow","comment_automation","bulk-api",null],"description":"Which Zernio surface produced this outgoing message: `human` (an\noperator in the Zernio inbox), `api` (a call to this API),\n`broadcast`, `sequence`, `workflow`, `comment_automation`, or\n`bulk-api` (POST /v1/whatsapp/bulk). Same vocabulary as the `source`\nfilter on the inbox analytics endpoints.\n\nAlways present, and `null` whenever the lineage is unknown: every\nincoming message, any outgoing message sent from the platform's own\napp, and every message stored before this field shipped\n(2026-08). Existing messages are NOT backfilled, so treat `null`\nas \"unknown\", never as \"sent by a human\".\n"}}}},"lastUpdated":{"type":"string","format":"date-time"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Inbox addon required"},"502":{"$ref":"#/components/responses/UpstreamFailure"},"503":{"$ref":"#/components/responses/TemporarilyUnavailable"}}},"post":{"x-resource-group":"messages","operationId":"sendInboxMessage","summary":"Send message","description":"Send a message in a conversation. Supports text, attachments, quick replies,\nbuttons, templates, and message tags. Attachment and interactive message\nsupport varies by platform.\n\nWhatsApp per-recipient rate limit: WhatsApp caps how many messages you may\nsend to the same recipient in a short window and rejects the excess with\nerror code `131056` (\"Too many messages sent to this recipient\"). Pace\nsends to a single recipient at roughly 10 per minute; bursts above that\nreturn a `400` with code `131056`. Sends to other recipients are\nunaffected, so parallelise across recipients rather than flooding one.\n\nWhatsApp template messages: to send an approved template into this\nconversation (required when the 24-hour customer-service window is\nclosed), use the `template` field with a single element carrying the\ntemplate reference: `{ \"elements\": [{ \"name\": ..., \"language\": ..., \"components\": [...] }] }`.\nSee the `template` field below for the exact shape. To send a template\nto a phone number you have no conversation with yet, use the\ncreate-conversation endpoint (POST /v1/inbox/conversations) instead.\n\nZernio resolves the exact APPROVED template name and language before any\nWhatsApp template send. A failed lookup or missing exact definition sends\nno message and returns code `platform_api_error`, type `platform_error`,\nand platform `whatsapp`. Sanitized Meta code, message, and\n`error_data.details` are returned in `platformError`; `details` identifies\n`phase: template_lookup`, the query-free endpoint, upstream status, and\nonly safe provider usage or retry headers.\n\nWhatsApp rich interactive messages (list, CTA URL, Flow, location request)\nare available via the `interactive` field. Tap events are delivered through\nthe `message.received` webhook with WhatsApp-specific `metadata` fields\n(`interactiveType`, `interactiveId`, `flowResponseJson`, `flowResponseData`).\n\n**Idempotency:** send an `Idempotency-Key` header to make retries safe\n(e.g. after a client-side timeout where delivery is unknown): same key +\nsame body replays the original response (with `Idempotent-Replayed: true`)\ninstead of sending the message a second time; same key + different body\nreturns 422; a key still in flight returns 409. Works for JSON and\nmultipart (file upload) requests alike. Keys are retained for 24 hours.\n\nOnly successful (2xx) responses are stored for replay: if the request\nthrows or returns a non-2xx status, the key is released so the same key\ncan be retried once the problem is fixed. The header therefore protects\nthe \"request succeeded but the response was lost\" case. For an ambiguous\nfailure (a 5xx or a network timeout), reconcile before retrying: a\nfailure after the platform already accepted the message also releases\nthe key, and a blind retry could send it twice. List the conversation's\nmessages first, and treat an empty result as inconclusive rather than\nas proof nothing was sent, since a send that failed while being recorded\nleaves no trace on our side.\n","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":"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. On WhatsApp, combining an image, video, or file with `buttons` renders the media as the header of one interactive reply-button message; audio cannot be combined with buttons."},"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."},"linkPreview":{"type":"boolean","default":true,"description":"WhatsApp only. Set false to send the message without a link-preview thumbnail for the first URL in the text. Defaults to true, which is how every WhatsApp text has been sent to date. Ignored on other platforms. 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\nas a voice message (PTT): the recipient sees the waveform + voice-note\nUI instead of a basic audio attachment. The audio file MUST be `.ogg`\nencoded with the OPUS codec (mono) per Meta's voice-message contract;\nother formats are rejected by WhatsApp. Ignored for non-audio attachments.\n"},"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.\n\nInstagram / Facebook: also mutually exclusive with `template`.\nA Meta message carries one body shape, so sending both is a 400\nrather than a silent drop of the buttons.\n\nWhatsApp: buttons always render as interactive reply buttons.\nOnly `title` and `payload` are used; `type`, `url`, and `phone`\nare ignored (WhatsApp has no URL/phone button in this field; use\nthe `interactive` field with `type: cta_url` for a link button).\n`payload` becomes the button reply ID delivered on the\n`message.received` webhook when the user taps. To send a simple\nreply-button message, provide `title` + `payload` and set\n`type: postback`, e.g.\n`{ \"type\": \"postback\", \"title\": \"Yes\", \"payload\": \"yes\" }`.\n\nCombine `buttons` with `attachmentUrl` and `attachmentType`\n`image`, `video`, or `file` to render one WhatsApp message with\na media header, body text, and reply buttons. Audio is not a\nsupported interactive header and returns 400 when combined\nwith buttons.\n","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.\n\nInstagram / Facebook: a generic template (carousel). Set `type: generic`\nand provide up to 10 `elements`, each with a `title` (required) and\noptional `subtitle`, `imageUrl`, and `buttons`. Mutually exclusive with\nthe top-level `buttons` field (sending both is a 400); put the card's\nbuttons on its `elements` instead. On Facebook, `imageAspectRatio`\n(`horizontal`, the default, or `square`) sets how Messenger renders the\nelement images; Instagram has no such setting and rejects it.\n\nWhatsApp: sends an approved WhatsApp template message, the only message\ntype WhatsApp accepts when the 24-hour customer-service window is closed.\nProvide exactly one element carrying the template reference:\n`{ \"elements\": [{ \"name\": \"order_update\", \"language\": \"en_US\", \"components\": [...] }] }`\n(`type` is ignored on WhatsApp). `components` is optional and is forwarded\nunchanged as the `template.components` array of Meta's Cloud API send\npayload; use it to fill body/header variables and button parameters, e.g.\n`[{ \"type\": \"body\", \"parameters\": [{ \"type\": \"text\", \"text\": \"John\" }] }]`.\nTemplates with media headers (image, video, document) must include the\nheader component with its media link here at send time. To send a template\nto a phone number with no existing conversation, or to have media headers\nfilled in automatically from the template definition, use the\ncreate-conversation endpoint (POST /v1/inbox/conversations) instead.\n","properties":{"type":{"type":"string","enum":["generic"],"description":"Template type. Required for Instagram/Facebook generic templates; ignored on WhatsApp."},"imageAspectRatio":{"type":"string","enum":["horizontal","square"],"description":"Facebook only. Aspect ratio Messenger renders element images at: horizontal (1.91:1, default) or square (1:1). A 400 on Instagram."},"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\nbuttons, Flow prompts, location requests, voice-call buttons, and\ncommerce messages (single product, product list, catalog, and\ncarousel). When set, takes priority over `buttons` and\n`quickReplies`. The shape mirrors Meta's Cloud API `interactive`\nobject for the types in the enum below.\n\nUse `buttons` / `quickReplies` for simple button replies\n(WhatsApp's `interactive.type: \"button\"`): the abstraction caps at\n3 buttons and handles the auto-conversion for you. Use this field\nonly for the types listed in the enum below.\n\nAll interactive messages are session messages: they can only be\nsent inside the 24-hour customer service window opened by the\nuser's last inbound message.\n\nCommerce types (`product`, `product_list`, `catalog_message`, and\nproduct carousels) require a Meta catalog connected to the\nWhatsApp Business Account in Commerce Manager. Media carousels\n(image/video cards) do not need a catalog.\n\nFor `product`, `body` is optional (WhatsApp renders the product\ncard itself) and `header` is not allowed (the product image is\nthe header). For `product_list`, a `header` with `type: \"text\"`\nis required. For `carousel`, top-level `header`/`footer` are not\nsupported; media goes on each card instead.\n\nFor `voice_call`, the message renders WhatsApp's native call\nbutton; tapping it starts a voice call to your business number.\nRequires WhatsApp Business Calling to be enabled on the sending\nnumber. The optional `parameters.payload` string is echoed back on\nthe `calls` webhook (as `cta_payload`) for attribution.\n\nFor `location_request_message`, `action` may be omitted (we default\nit to `{ \"name\": \"send_location\" }`). WhatsApp renders a localized\n\"Send location\" button; the user's reply arrives as a regular\nlocation message in the conversation.\n\nFor `request_contact_info`, `action` may be omitted (we default it\nto `{ \"name\": \"request_contact_info\" }`). WhatsApp renders a\nlocalized share button that cannot be relabelled, so put the reason\nfor asking in `body.text`: this is a consent prompt, and a bare\nrequest converts badly. The reply arrives as an inbound `contacts`\nmessage with `metadata.contactsOrigin` set to `contact_request`,\nand we fold the shared number back into the contact automatically.\nA `contacts` message with origin `other` is a card the user picked\nfrom their address book and is NOT proof of their own number.\n\nFor `catalog_message`, `action` may also be omitted (we default it\nto `{ \"name\": \"catalog_message\" }`).\n\nFor `address_message`, `parameters.country` is required (Meta\nrejects the whole send without it); everything else in\n`parameters` (`values`, `saved_addresses`, `validation_errors`)\nis forwarded to Meta as-is. This is Meta's native structured\nshipping-address capture, generally available in India as of\n2026-08; check Meta's documentation for current country\navailability before relying on it elsewhere. The submitted\naddress arrives as an `nfm_reply` on the `message.received`\nwebhook, same as a Flow submission, but with\n`metadata.nfmReplyName` set to `address_message` so you can\ntell the two apart.\n\nTap events come back via the `message.received` webhook with\n`metadata.interactiveType` set to `list_reply` or `nfm_reply`.\nCarts submitted from commerce messages arrive as `metadata.order`;\nproduct inquiries arrive as `metadata.referredProduct`.\n","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","address_message"],"description":"Which interactive layout to render."},"header":{"type":"object","description":"Optional header shown above the body. Required with\n`type: \"text\"` for `product_list`; not allowed for `product`\nor `carousel`.\n","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\n`product`. Requires a Meta catalog connected to the\nWhatsApp Business Account in Commerce Manager.\n","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\n`product_list`. Requires a Meta catalog connected to the\nWhatsApp Business Account in Commerce Manager.\n","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\n`catalog_message`. May be omitted entirely; it is defaulted\nto `{ \"name\": \"catalog_message\" }`. Requires a Meta catalog\nconnected to the WhatsApp Business Account in Commerce\nManager.\n","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`.\nCarries 2-10 cards, either all product cards\n(`type: \"product\"`, all referencing the same `catalog_id`)\nor media cards (any other `type`, e.g. `cta_url`, with a\nrequired image/video `header` on each card). `card_index`\n(0-9, non-repeating) is auto-filled sequentially when\nomitted. Product carousels require a Meta catalog connected\nto the WhatsApp Business Account in Commerce Manager; media\ncarousels do not.\n","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`)."}}}}}},{"type":"object","description":"Address-request action. `type` on the parent must be\n`address_message`. Renders Meta's native structured\naddress form in-chat (generally available in India as\nof 2026-08; check Meta's documentation for current\ncountry availability).\n","required":["name","parameters"],"properties":{"name":{"type":"string","enum":["address_message"]},"parameters":{"type":"object","required":["country"],"properties":{"country":{"type":"string","description":"ISO 3166-1 alpha-2 country code Meta should localize the address form for (e.g. IN). Required: Meta rejects the send without it."},"values":{"type":"object","additionalProperties":true,"description":"Optional pre-filled address field values."},"saved_addresses":{"type":"array","items":{"type":"object","additionalProperties":true},"description":"Optional list of the recipient's previously saved addresses to offer as quick picks."},"validation_errors":{"type":"object","additionalProperties":{"type":"string"},"description":"Optional per-field error messages to show when re-prompting after a failed validation."}}}}}]}}},"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 (delivered as message.platformMessageId on webhooks, and as `id` on each entry of the list-messages endpoint). On Slack it threads the reply (thread_ts) instead of quoting. Instagram and Facebook Messenger do not support send-side quote replies: the message is sent without a quote and the successful response includes a warnings entry with code ignored_field and param replyTo. Other platforms without send-side reply support ignore this field."},"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"}}}}}}}}},"examples":{"whatsappImageWithReplyButtons":{"summary":"WhatsApp image, body text, and reply buttons","description":"The recipient sees one WhatsApp message containing the property\nimage as its header, \"Reply yes to continue.\" as the body, and\ntwo tappable reply buttons labelled Yes and No. A tap arrives on\nthe `message.received` webhook as `metadata.interactiveType:\nbutton_reply`; `metadata.interactiveId` is `btn_0` or `btn_1`.\n","value":{"accountId":"6a7adc04d0fe733d1a1bed76","message":"Reply yes to continue.","attachmentUrl":"https://cdn.example.com/property.jpg","attachmentType":"image","buttons":[{"type":"postback","title":"Yes","payload":"btn_0"},{"type":"postback","title":"No","payload":"btn_1"}]}}}},"multipart/form-data":{"schema":{"type":"object","required":["accountId"],"properties":{"accountId":{"type":"string","description":"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. On WhatsApp, combining an image, video, or document upload with buttons renders the upload as the interactive message header; audio plus buttons is rejected."},"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). On WhatsApp, an accompanying image, video, or document attachment becomes the interactive message header."},"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 (delivered as message.platformMessageId on webhooks, and as `id` on each entry of the list-messages endpoint). On Slack it threads the reply (thread_ts) instead of quoting. Instagram and Facebook Messenger do not support send-side quote replies: the message is sent without a quote and the successful response includes a warnings entry with code ignored_field and param replyTo. Other platforms without send-side reply support ignore this field."},"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"},"warnings":{"type":"array","description":"Present when a successful send ignored replyTo on Instagram or Facebook Messenger. The message was sent without a quote; do not retry it to apply the reply.","items":{"type":"object","required":["code","param","message"],"properties":{"code":{"type":"string","enum":["ignored_field"]},"param":{"type":"string","enum":["replyTo"]},"message":{"type":"string","description":"Human-readable explanation of the ignored field."}}}},"data":{"type":"object","properties":{"messageId":{"type":"string","description":"Platform id of the sent message (not returned for Reddit). For WhatsApp this is the raw Meta wamid, the same id delivered as message.platformMessageId on webhooks and delivery-status updates, and the value to pass as replyTo to quote-reply."},"conversationId":{"type":"string","description":"Zernio conversation id, echoed so the thread can be read back or replied to. It equals the id the list-conversations endpoint returns for Telegram, WhatsApp, SMS and Slack; for Facebook, Instagram, Bluesky and Reddit that endpoint returns the platform thread id instead, so do not correlate the two by equality. For X, when the request addressed the conversation by its Twitter dm_conversation_id, that platform id is echoed back instead. Omitted when the send succeeded but the conversation could not be resolved to a stored record."},"attachments":{"type":"array","description":"Echo of the sent attachment with its resolved public URL, when one is available (Facebook, Instagram, Telegram, WhatsApp).","items":{"type":"object","properties":{"type":{"type":"string"},"url":{"type":"string"}}}},"messageIds":{"type":"array","description":"Facebook/Instagram only. Present when an attachment and text were both requested: Meta has no single body shape for both, so the send is two Meta messages under the hood. First element === messageId (the attachment); second is the follow-up text.","items":{"type":"string"}},"partialFailure":{"type":"object","description":"Facebook/Instagram only. The attachment was delivered but the follow-up text message was rejected by Meta and was not stored; the response is still a 200 because the attachment send succeeded.","properties":{"part":{"type":"string","enum":["text"]},"error":{"type":"string"},"platformError":{"type":"object","description":"Meta's own diagnostic fields for the rejected follow-up, same shape as the 400 response's platformError.","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)"}}}}}}}}}}}},"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). Meta rejections (e.g. sending outside the messaging window) arrive with code platform_api_error, type platform_error, and platform + platformError set.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"type":{"type":"string","description":"Present on Meta pass-through rejections: platform_error when Meta rejected the send (see platform/platformError below), invalid_request_error for validation failures.","enum":["platform_error","invalid_request_error"]},"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; DIRECT_SEND_NOT_ELIGIBLE and DIRECT_SEND_BLOCKED mean the WhatsApp Business Account needs Meta to grant or restore Direct Send access; DIRECT_SEND_LIMITED is temporary, Meta lifts it on its own; platform_api_error means Meta itself rejected the send (see platformError).","enum":["PLATFORM_LIMITATION","MISSING_PARTICIPANT","INVALID_TEMPLATE_HEADER","DIRECT_SEND_NOT_ELIGIBLE","DIRECT_SEND_LIMITED","DIRECT_SEND_BLOCKED","platform_api_error"]},"platform":{"type":"string","description":"Present alongside code platform_api_error. The platform that rejected the send (e.g. instagram, facebook, whatsapp)."},"platformError":{"type":"object","description":"Instagram, Facebook, or WhatsApp. Meta's diagnostic fields for the rejected send or template lookup. WhatsApp lookup errors retain only code, message, and error_data.details. 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, or Meta rejected the send outside the messaging window (type platform_error, code platform_api_error, platform, platformError with code/subcode/fbtraceId/type)","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"409":{"$ref":"#/components/responses/IdempotencyKeyInFlight"},"422":{"$ref":"#/components/responses/IdempotencyKeyReused"},"429":{"description":"Meta rejected the WhatsApp template-definition lookup. No message was sent.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WhatsAppTemplateLookupError"}}}},"500":{"description":"The platform rejected or failed the send. Zernio does NOT retry a send internally: a message send is not idempotent, and an opaque upstream failure (for example WhatsApp 131000) does not say whether the message was delivered. Retrying this request may deliver the message twice. Retry only if your use case tolerates a duplicate. Meta 5xx failures also arrive as a platform_error envelope (code platform_api_error, with platform and platformError set)."},"502":{"description":"The exact approved WhatsApp template definition is unavailable, or the platform returned an upstream failure. No WhatsApp template message is sent when definition lookup fails.","content":{"application/json":{"schema":{"anyOf":[{"$ref":"#/components/schemas/ErrorResponse"},{"$ref":"#/components/schemas/WhatsAppTemplateLookupError"}]}}}},"503":{"$ref":"#/components/responses/TemporarilyUnavailable"},"default":{"description":"Meta rejected the WhatsApp template-definition lookup with another upstream status. No message was sent.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/WhatsAppTemplateLookupError"}}}}}}},"/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\n`url` on a WhatsApp `attachments[]` entry points at, in both the\n`message.received` webhook and the List messages response.\n\n**This is an authenticated endpoint, not a public link.** Send\n`Authorization: Bearer <your API key>` exactly as you would for any other\ncall. Passing the URL straight to a browser, an LLM vision API, or a\nno-code \"download file\" step without the header returns `401`. This is\nthe most common integration mistake on this endpoint, and it differs from\nInstagram, Facebook and Telegram, whose `attachments[].url` is a direct\nCDN link that needs no header.\n\n**Fetch on receipt, not lazily.** WhatsApp media lives in Meta's media\nstore, not ours, and it is removed after a limited retention window\n(currently 7 days, and Meta has been dropping some inbound media sooner).\nOnce Meta drops it the media is unrecoverable and this endpoint answers\n`400` permanently, so retrying will never succeed. Download and store the\nbytes when the webhook arrives.\n","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.\nOnly supported for Telegram. Returns 400 for other platforms.\n","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":"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:\n- Telegram: Full delete (bot's own messages anytime, others if admin)\n- X: Full delete (own DM events only)\n- Bluesky: Delete for self only (recipient still sees it)\n- Reddit: Delete from sender's view only\n- Facebook, Instagram, WhatsApp: Not supported (returns 400)\n","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":"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:\n- Facebook Messenger: Shows \"Page is typing...\" for 20 seconds\n- 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.\n- Telegram: Shows \"Bot is typing...\" for 5 seconds\n- 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.\n- All others: Returns 200 but no-op (platform doesn't support it)\n\nTyping 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).\n","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":"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}/thread-control":{"post":{"x-resource-group":"messages","operationId":"setConversationThreadControl","summary":"Hand a conversation to or from Meta Business Agent","description":"WhatsApp only, on numbers with Meta Business Agent enabled. Wraps Meta's thread control:\n- `release`: hand the conversation back to the agent so it resumes answering. You must currently hold control (sending any message takes it implicitly).\n- `take`: take control before sending anything, so the agent stops replying while an operator reads the thread. Meta accepts this only from the business configured as the number's escalation partner; other apps take control by sending a message.\n- `pass`: transfer control to the number's configured escalation partner, or to the agent with `target: ai_agent`. Meta's Cloud API currently rejects it (\"Pass action is not supported\", verified 2026-09-08); use `release` to hand a thread back to the agent.\n\nThe conversation's `threadControl` follows the result; a `conversation.control_changed` webhook fires when Meta later reports the change.\n","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","action"],"properties":{"accountId":{"type":"string","description":"Social account ID"},"action":{"type":"string","enum":["release","take","pass"]},"target":{"type":"string","enum":["ai_agent"],"description":"With action pass: send control to Meta Business Agent instead of the escalation partner."},"metadata":{"type":"string","maxLength":2000,"description":"Free-form note forwarded verbatim to the app receiving control (its messaging_handovers webhook)."}}}}}},"responses":{"200":{"description":"Control transferred","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"control":{"type":"object","properties":{"owner":{"type":"string","enum":["app","ai_agent","other"]}}}}}}}},"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.\n\nFor WhatsApp, this also sends read receipts (blue ticks) to the contact,\nEXCEPT on coexistence accounts (where the WhatsApp Business app on the\ncustomer's phone owns read state and we never override it).\n\nThis is the explicit, human-driven counterpart to `GET .../messages`,\nwhich is side-effect-free and does NOT mark anything read. Call this when\na user actually views the conversation.\n","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":"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:\n- Telegram: Supports a subset of Unicode emoji reactions\n- WhatsApp: Supports any standard emoji (one reaction per message per sender)\n- Instagram and Facebook Messenger: Any standard emoji, subject to Meta's 24h messaging window\n- Slack: The emoji must have a Slack name (e.g. `:thumbsup:`); unnamed characters return 400\n- All others: Returns 400 (not supported)\n","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 (as returned by GET /messages) or the Zernio message ID (as returned by the reaction webhook)"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","emoji"],"properties":{"accountId":{"type":"string","description":"Account ID"},"emoji":{"type":"string","description":"Emoji character (e.g. \"👍\", \"❤️\")","example":"👍"}}}}}},"responses":{"200":{"description":"The platform accepted the reaction request. This does not guarantee the reaction was placed: the platform never confirms what it acted on.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"messageId":{"type":"string","description":"The Zernio message ID the reaction was resolved against"},"platformMessageId":{"type":"string","description":"The platform message ID the reaction was sent for"}}}}}},"400":{"description":"Platform does not support reactions or invalid request"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Inbox addon required"},"404":{"description":"Account, conversation or message not found (message_not_found when messageId does not resolve to a message in this conversation)"}}},"delete":{"x-resource-group":"messages","operationId":"removeMessageReaction","summary":"Remove reaction","description":"Remove a reaction from a message. Platform support:\n- Telegram: Send empty reaction array to clear\n- WhatsApp: Send empty emoji to remove\n- Instagram and Facebook Messenger: Sends Meta's `unreact` action; the emoji does not need to be repeated\n- Slack: Removes the reaction we previously sent on that message\n- All others: Returns 400 (not supported)\n","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 (as returned by GET /messages) or the Zernio message ID (as returned by the reaction webhook)"},{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"Account ID"}],"responses":{"200":{"description":"The platform accepted the removal request. This does not guarantee a reaction was removed: the platform never confirms what it acted on, and a reaction placed by the other participant cannot be removed (platform rule). Check `fromMe` on GET /messages to know who placed a reaction.","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"messageId":{"type":"string","description":"The Zernio message ID the removal was resolved against"},"platformMessageId":{"type":"string","description":"The platform message ID the removal was sent for"}}}}}},"400":{"description":"Platform does not support reactions or invalid request"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Inbox addon required"},"404":{"description":"Account, conversation or message not found (message_not_found when messageId does not resolve to a message in this conversation)"}}}},"/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.\nThe URL can be used as attachmentUrl when sending inbox messages.\n\nFiles are stored in temporary storage and auto-delete after 7 days.\nMaximum file size is 25MB.\n\nUnlike /v1/media/upload (which uses upload tokens for end-user flows),\nthis endpoint takes your API key in the Authorization header, for programmatic use.\n","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.\n\nInstagram and Facebook sign DM media urls per request and expire them, so\nthe `url` on a message is a snapshot: it works when you read the message\nand stops working later. This endpoint checks the stored url and, when it\nhas gone stale, re-mints the message's media from Meta and persists it\nbefore answering. The message id never expires, so this URL is the one to\nstore. It is returned ready-made on each attachment as `refreshUrl` when\nyou read a message over REST.\n\n**Webhook payloads do not carry `refreshUrl`**, so a webhook-driven\nintegration builds this URL itself. Every piece is in the event:\n`message.conversationId`, `message.platformMessageId`, the attachment's\nzero-based position, and `account.accountId`. **`accountId` is a\nrequired query parameter**; omitting it returns `400`\n`missing_required_field`, which is the same requirement\n`GET /v1/whatsapp/media/{mediaId}` has.\n\nBy default it responds `302` to the live media url, so it can be used\ndirectly as an `<img src>` on a browser session. API-key integrators\nshould pass `?format=json` and read `url` off the body, since a browser\ncannot attach an Authorization header to an image request.\n\nOnly Instagram and Facebook media can be re-minted. On other platforms\nthe stored url is returned as-is when it still resolves, and `404`\notherwise.\n","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":"Account ID. Required: without it the request returns 400 missing_required_field."},{"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.\n\nResponses are cached for up to 10 minutes, so the feed may lag new comments by that\nwindow. Do not poll this endpoint for real-time updates: subscribe to the\n`comment.received` webhook, which fires for every new comment across your posts and\ncarries the post reference needed to keep this list current.\n\nFor users with the Ads add-on (accounts on usage-based billing always qualify), the user's Meta ads\n(boosted/dark posts) are included too. There's one row per (ad, placement-with-comments):\nan ad that runs on both Facebook feed and Instagram feed produces up to two rows (the\nPage dark post and the IG media have separate comment threads), each flagged\n`isAd: true` with `adId` and `placement` (`id` is `{adId}:{placement}`). Use\n`?platform=metaads` to return *only* ad rows; passing `facebook`/`instagram` returns\n*organic* posts only (no ads); omitting `platform` returns both. Fetch a row's thread\nfrom GET /v1/ads/{adId}/comments?placement={placement}. Ad comment counts are read with\nthe Marketing API token (Facebook side) or the connected Instagram account's token\n(Instagram side); a row whose count can't be read is omitted.\n\nPagination walks each account's platform listing. Following `nextCursor` reaches past\nthe first page on Facebook, Instagram, Threads, LinkedIn and YouTube, since they are\nthe platforms that support a server-side date window; on the others the listing stops\nat its first page. Cursor pagination is only coherent for the default sort\n(`sortBy=date`, `sortOrder=desc`): with `sortOrder=asc`, or with `sortBy=comments`,\nthe cursor filter does not match the sort order and the second page is unreliable.\n\n`nextCursor` is opaque: pass it back verbatim, never construct or parse it, its\ncomposition may change without notice. Because each page re-queries a live window,\nresults can still shift between requests, so dedupe by `id` on the client.\n\n`commentCount` semantics differ by platform: YouTube's includes replies, Facebook's counts\ntop-level comments only.\n","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","tiktok","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. `tiktok` covers accounts connected through the TikTok Business app only; developer-app TikTok accounts are returned under `meta.accountsSkipped`."},{"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 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 (e.g. TOKEN_EXPIRED, or X_INBOX_NOT_ENABLED for an X account whose owner has not enabled X inbox)"},"retryAfter":{"type":["integer","null"],"description":"Seconds to wait before retry (rate limits)"}}}},"lastUpdated":{"type":"string","format":"date-time"},"accountsSkipped":{"type":"array","description":"Connected accounts that were not queried: their platform does not support this feature, or the account is not enabled for it","items":{"type":"object","properties":{"accountId":{"type":"string"},"platform":{"type":"string"}}}}}}}}}}},"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.\n\nOn Facebook and Instagram, passing a COMMENT id as `postId` is also supported and\nreturns that comment's replies instead of the post's top-level comments. This is not\navailable on YouTube, where `postId` must be a video id.\n\nResponses are cached for up to 10 minutes, so a page may lag new comments by that\nwindow. Do not poll this endpoint for real-time updates: subscribe to the\n`comment.received` webhook, which delivers new comments as they arrive. Your own\nwrites (creating, replying to, or deleting a comment) refresh the cache immediately.\n\nTikTok is served for accounts connected through the TikTok for Business app: `postId`\nis the TikTok video id, each top-level comment carries up to three inline replies, and\n`commentId` pages the full reply list of one comment. Developer-app TikTok accounts\nreturn 400 with code `PLATFORM_LIMITATION`.\n","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. On Facebook and Instagram, a comment ID is also accepted here and returns that comment's replies.","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, returned by a previous call as `pagination.cursor`. This is the platform's own opaque paging value passed through verbatim: never construct, decode or validate it client-side."},{"name":"commentId","in":"query","schema":{"type":"string"},"description":"(Reddit and TikTok 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 verified badge type. Only present for X comments."}}},"likeCount":{"type":"integer"},"replyCount":{"type":"integer","description":"The platform's own reply count, which includes hidden and deleted replies. Can exceed replies[].length even when repliesHasMore is false or absent."},"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"}},"repliesHasMore":{"type":"boolean","description":"Facebook only. True when replies[] (capped at 10) does not hold the comment's full reply thread; fetch the rest by passing the comment id as postId to GET /v1/inbox/comments/{postId}. Absent (not false) on every other platform, including Instagram, which has no equivalent signal."},"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, X, Bluesky, Reddit, LinkedIn)"},"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":"ID of the parent comment. Present on entries inside replies[] for Facebook, Instagram and X. On X it is also present on top-level entries, where it holds the ID of the post replied to. Omitted entirely (key absent, not null) on top-level Facebook and Instagram entries and on every other platform, which express the parent relationship only through replies[] nesting."},"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\nsingle round-trip. Lets integrators render a preview of the post the user is commenting on\nwithout an additional request. Absent for non-Reddit platforms and when the upstream\nresponse is missing the post listing (deleted post, malformed response).\n","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\n(code USE_AD_COMMENTS_ENDPOINT; the response includes `adId` and `adCommentsUrl`), or the upstream\nplatform rejected the request (type platform_error, code platform_api_error; the provider's own\npayload is in platformError). Meta returns code 100 with error_subcode 33 both for a story past\nits 24h life and for a deleted post, so the two are indistinguishable from the response.\n"},"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.\n\n**Idempotency:** send an `Idempotency-Key` header to make retries safe\n(e.g. after a client-side timeout where delivery is unknown): same key +\nsame body replays the original response (with `Idempotent-Replayed: true`)\ninstead of posting the comment a second time; same key + different body\nreturns 422; a key still in flight returns 409. Keys are retained for 24\nhours and are scoped to the credential and to this exact path, so reusing\na key against a different postId returns 422 rather than replaying the\nother post's response.\n\nOnly successful (2xx) responses are stored for replay. If the request\nthrows or returns a non-2xx status the key is released, so the header\nprotects the \"request succeeded but the response was lost\" case. After an\nambiguous failure (a 5xx or a network timeout) list the post's comments\nbefore retrying with the same key, and treat an empty result as\ninconclusive rather than as proof nothing was posted.\n","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"}},{"$ref":"#/components/parameters/IdempotencyKeyHeader"}],"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)"},"409":{"$ref":"#/components/responses/IdempotencyKeyInFlight"},"422":{"$ref":"#/components/responses/IdempotencyKeyReused"},"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, Threads, LinkedIn, Reddit, Bluesky, X (Twitter), YouTube, and TikTok (accounts connected through the TikTok for Business app). Not supported on Google Business (reviews only).\nRequires accountId and commentId query parameters.\n","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,"description":"For LinkedIn, accepts either the numeric comment ID or the composite comment URN returned by the comments listing (e.g. urn:li:comment:(threadUrn,id))","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.\n\nReddit keeps the same comment id after an edit. Reddit exposes no API to edit a post\ntitle, and a link post has no editable body. To edit a published post's body, use\n`POST /v1/posts/{postId}/edit`.\n","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 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.\n\nUse this to work a moderation queue: approve a held comment (`published`), reject it\n(`rejected`), or send it back for review (`heldForReview`).\n\nThe request must be authorized by the owner of the channel or video the comment\nbelongs to. You cannot moderate comments on videos you do not own.\n\nThis is distinct from `POST /v1/inbox/comments/{postId}/{commentId}/hide`, which\ncovers Facebook, Instagram, Threads, and X and does not apply to YouTube.\n","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 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.\n"}}},"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, X, and TikTok\n(accounts connected through the TikTok for Business app).\nHidden comments are only visible to the commenter and page admin.\nFor X, the reply must belong to a conversation started by the authenticated user.\n","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 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, X, and\nTikTok (accounts connected through the TikTok for Business app).\n","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}/pin":{"post":{"x-resource-group":"engagement","operationId":"pinInboxComment","summary":"Pin comment","description":"Pin a top-level comment to the top of a post's comment section. TikTok accounts\nconnected through the TikTok for Business app only; every other platform returns 400.\n","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 pinned","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"},"commentId":{"type":"string"},"pinned":{"type":"boolean"},"platform":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Inbox addon required"}}},"delete":{"x-resource-group":"engagement","operationId":"unpinInboxComment","summary":"Unpin comment","description":"Unpin a previously pinned comment. TikTok accounts connected through the TikTok for\nBusiness app only.\n","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 unpinned","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string"},"commentId":{"type":"string"},"pinned":{"type":"boolean"},"platform":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"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, X,\nBluesky, Reddit, LinkedIn, and Instagram in limited release (see below). For\nBluesky, the cid (content identifier) is\nrequired in the request body. For LinkedIn, pass the composite comment URN returned\nby the comments endpoints as commentId; an optional reactionType picks the reaction\n(defaults to LIKE), and accounts connected before the social-feed scopes were\nrequested get a 403 with code `linkedin_reconnect_required`.\n\nInstagram is in LIMITED RELEASE and not generally available: the call needs\n`instagram_manage_engagement`, which Meta has so far granted this app only under\nStandard Access, so it works for app admins, developers and testers of our Meta app\nand returns a 403 with code `PLATFORM_BETA_RESTRICTED` for every other account.\nThat restriction lifts when Meta App Review grants Advanced Access; the constraints\nbelow apply once it does.\n\nInstagram covers comments and replies on feed posts, reels and carousels. Only an\naccount connected through Facebook Login can be granted\n`instagram_manage_engagement`: an Instagram Login connection returns a 400 with\ncode `instagram_likes_require_facebook_login`, and an account whose token predates\nthe permission returns a 403 with code `reconnect_required`. Content from private\naccounts cannot be liked. Instagram also enforces a burst limit of 50 like or\nunlike calls per 5 seconds per Instagram account, and exceeding it locks that\naccount out of the like API for an hour, so pace bulk loops.\n","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 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"},"alreadyReacted":{"type":"boolean","description":"LinkedIn only: the account already had this exact reaction, so nothing was created"},"reactionType":{"type":"string","description":"LinkedIn only: the reaction type now in effect"},"platform":{"type":"string"}}}}}},"400":{"description":"Platform does not support liking comments"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Inbox addon required, or the account is missing the platform permission"},"409":{"description":"LinkedIn only: the account already holds a different reaction on this target (code invalid_resource_state); remove it before creating another."}}},"delete":{"x-resource-group":"engagement","operationId":"unlikeInboxComment","summary":"Unlike comment","description":"Remove a like from a comment. Supported platforms: Facebook, X, Bluesky,\nReddit, LinkedIn, and Instagram in limited release. For Bluesky, the likeUri query\nparameter is required. Instagram has the same limited release, Facebook Login,\n`instagram_manage_engagement` and burst-limit constraints as liking.\n","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, or the account is missing the platform permission"}}}},"/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,\nX, Facebook, YouTube, Bluesky, and Instagram in limited release (see below).\nThreads, TikTok and Pinterest\nexpose no like endpoint in their APIs and return 400. Reddit returns 400 too,\npointing at `POST /v1/accounts/{accountId}/reddit-vote`, which covers upvote,\ndownvote and clear on both posts and comments.\n\nThe account does not have to be the one that published the post, which is what\nmakes executive engagement possible: pass an exec's `accountId` and the brand\npost's ID. `postId` accepts either a Zernio post ID or the platform's native post\nID. A Zernio post ID resolves to the entry for `accountId`, falling back to the\npost's single entry on the same platform (two entries on that platform is a 400,\nso pass the native ID).\n\nLinkedIn requires the `w_member_social_feed` / `w_organization_social_feed`\nscopes, which are not retroactive: accounts connected before those were requested\nget a 403 with code `linkedin_reconnect_required` until the user reconnects the\naccount. YouTube spends 50 quota units per call.\n\nInstagram is in LIMITED RELEASE and not generally available: the call needs\n`instagram_manage_engagement`, which Meta has so far granted this app only under\nStandard Access, so it works for app admins, developers and testers of our Meta app\nand returns a 403 with code `PLATFORM_BETA_RESTRICTED` for every other account.\nThat restriction lifts when Meta App Review grants Advanced Access; the constraints\nbelow apply once it does.\n\nInstagram covers feed images, reels and carousels (stories and private-account\nmedia are not likeable). Only an account connected through Facebook Login can be\ngranted `instagram_manage_engagement`: an Instagram Login\nconnection returns a 400 with code `instagram_likes_require_facebook_login`, and an\naccount whose token predates the permission returns a 403 with code\n`reconnect_required`. Instagram also enforces a burst limit of 50 like or unlike\ncalls per 5 seconds per Instagram account, and exceeding it locks that account out\nof the like API for an hour, so pace bulk loops.\n","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 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"},"alreadyReacted":{"type":"boolean","description":"LinkedIn only: the account already had this exact reaction, so nothing was created"},"reactionType":{"type":"string","description":"LinkedIn only: the reaction type now in effect"}}}}}},"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"},"409":{"description":"LinkedIn only: the account already holds a different reaction on this target (code invalid_resource_state); remove it before creating another."}}},"delete":{"operationId":"unlikePost","summary":"Unlike post","description":"Remove this account's like from a post. Supported platforms: LinkedIn, X,\nFacebook, YouTube, Bluesky, and Instagram in limited release. On YouTube this clears\nthe rating. Instagram has the same limited release, Facebook Login,\n`instagram_manage_engagement` and burst-limit constraints as liking. For Bluesky,\n`likeUri` (returned when the post was liked) is required. Reddit uses\n`POST /v1/accounts/{accountId}/reddit-vote` with `direction: 0`.\n","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 direct message to the author of a comment. Supported on Instagram and Facebook only.\nOne reply per comment, must be sent within 7 days. Optionally attach interactive elements:\n`quickReplies` (chips above the keyboard, max 13) or `buttons` (1-3 inline postback/url\nbuttons rendered in the same bubble via Meta's button_template). Chips do not render in\nthe Instagram Message Requests folder. Since late August 2026 Instagram refuses buttons,\ncards and attachments to commenters who do not follow the account (Meta code 2, subcode\n1545133, returned here as a non-retryable 400 that says so), and the failed call still\nconsumes the comment's single private reply. To reach non-followers send plain text and\nadd buttons once they reply. `quickReplies` and `buttons` are mutually exclusive. When\nthe comment's single private reply is spent (by this call or an earlier one) the 400\ncarries `details.privateReplyConsumed: true`; never retry it.\n","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 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\nInstagram and Messenger apps (not on web). Maximum 13 entries. Mutually\nexclusive with `buttons`. Note: chips do NOT render in the Instagram\nMessage Requests folder where DMs from non-followers land. Use `buttons`\ninstead for cold reach.\n","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\nvia Meta's button_template. Visible in the Instagram Message Requests\nfolder (unlike quick replies). Mutually exclusive with `quickReplies`.\n","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":{"$ref":"#/components/schemas/ErrorResponse"},"examples":{"platformNotSupported":{"summary":"Platform not supported","value":{"error":"Private replies to comments are only supported on Instagram and Facebook.","type":"invalid_request_error","code":"invalid_field_value","param":"accountId"}},"followGated":{"summary":"Media refused, commenter does not follow the account (reply consumed)","value":{"error":"Instagram refused this private reply because it carries a card, buttons or an attachment and the commenter does not follow this account. Meta delivers only plain text to non-followers. This attempt consumed the comment's single private reply, so do not retry it; send a text-only private reply to reach non-followers.","type":"platform_error","code":"platform_api_error","platform":"instagram","platformError":{"code":2,"error_subcode":1545133,"is_transient":false,"message":"Service temporarily unavailable","error_user_title":"Your message can't be delivered","error_user_msg":"You can't send media to Jejum de Daniel unless they follow you."},"details":{"privateReplyConsumed":true}}},"alreadyReplied":{"summary":"Already sent a private reply (reply consumed, Meta subcode 2534023)","value":{"error":"A private reply has already been sent to this comment. Instagram only allows one private reply per comment.","type":"platform_error","code":"platform_api_error","platform":"instagram","details":{"privateReplyConsumed":true}}},"commentTooOld":{"summary":"Comment older than 7 days","value":{"error":"The comment is older than 7 days. Instagram only allows private replies within 7 days of the comment being posted.","type":"platform_error","code":"platform_api_error","platform":"instagram"}},"declinedByMeta":{"summary":"Meta declined the private reply on this media (subcode 2534066, reply NOT consumed)","value":{"error":"Instagram declined this private reply (Meta error 2534066). Despite Meta's wording, the token and permissions are fine and the comment ID is valid, so reconnecting the account or retrying this comment will not change the result.","type":"platform_error","code":"platform_api_error","platform":"instagram","platformError":{"code":200,"error_subcode":2534066,"type":"IGApiException","message":"Please check if access token has enough IG permissions granular scopes for IG private reply. Or, verify if the comment ID is valid"}}},"missingMessage":{"summary":"Missing message","value":{"error":"message is required and must be a non-empty string","type":"invalid_request_error","code":"missing_required_field","param":"message"}}}}}},"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.\nRate limit: 50 requests per 15-min window. Shares the 300/3hr creation limit with tweet creation.\n","tags":["Twitter Engagement"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","tweetId"],"properties":{"accountId":{"type":"string","description":"The 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).\n","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.\nRequires the bookmark.write OAuth scope.\nRate limit: 50 requests per 15-min window.\n","tags":["Twitter Engagement"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","tweetId"],"properties":{"accountId":{"type":"string","description":"The 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.\n","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.\nRequires the follows.write OAuth scope.\nFor protected accounts, a follow request is sent instead (pending_follow will be true).\n","tags":["Twitter Engagement"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","targetUserId"],"properties":{"accountId":{"type":"string","description":"The account ID"},"targetUserId":{"type":"string","description":"The X 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.\n","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 X 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.\nThe query string is passed through to X unchanged and supports X's search operators\n(`from:user`, `-is:retweet`, `is:reply`, `lang:en`, `\"exact phrase\"`, `conversation_id:123`, boolean `OR`, ...).\nStandalone operators like `is:` / `has:` / `lang:` must be combined with a keyword or `from:` clause.\n\nTo reply to a found tweet, pass its `id` as the twitter platform entry's `platformSpecificData.replyToTweetId` when creating a post.\n\nRate limit: 300 requests per 15-min window per connected account.\n","tags":["Twitter Engagement"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"The 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.\n\nUse this to render a post you are referencing, e.g. the tweet quoted by a quote-style post.\nUnlike `/v1/twitter/search` this is not limited to the last 7 days and works for any tweet\nvisible to the connected account.\n\nBilled as an X posts read ($0.005). Repeat lookups of the same tweet within the same UTC day\nare charged once.\n","tags":["Twitter Engagement"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"The 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.\nCurrently supports LinkedIn organization mentions.\n\nRequires Inbox addon.\n","tags":["Mentions"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","schema":{"type":"string"},"description":"Filter by 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.\n\nTwo shapes, selected by whether `commentId` is present:\n\n- **Comment mention** (someone @mentioned the account inside a comment): pass both\n  `mediaId` and `commentId`. Instagram posts a reply under that comment.\n- **Caption mention** (someone @mentioned the account in their media caption, so no\n  comment exists): pass `mediaId` only. Instagram posts a comment on their media.\n\nStory mentions are not supported by Instagram's API.\n\n`GET /v1/inbox/mentions` currently returns LinkedIn mentions only and does\nnot surface Instagram mentions. Source `mediaId` and `commentId` from Instagram's\n`comments` webhook, which is where mention notifications are delivered for accounts\nconnected through Instagram Login.\n","tags":["Mentions"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","mediaId","message"],"properties":{"accountId":{"type":"string","description":"The Instagram 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 Profile accounts. Aggregates data with filtering and sorting options.\nSupported platforms: Facebook, Google Business Profile.\n","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 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 Profile 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 Google Business Profile location id the review belongs to. Google Business Profile only; absent for other platforms."},"locationName":{"type":["string","null"],"description":"Human-readable Google Business Profile location display name. Google Business Profile 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 Profile 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 Profile only; 0 for other platforms."},"photos":{"type":"array","description":"Photos attached to the review. Google Business Profile 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"},"accountsSkipped":{"type":"array","description":"Connected accounts that were not queried: their platform does not support this feature, or the account is not enabled for it","items":{"type":"object","properties":{"accountId":{"type":"string"},"platform":{"type":"string"}}}}}},"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.\n\n**Idempotency:** send an `Idempotency-Key` header to make retries safe\n(e.g. after a client-side timeout where delivery is unknown): same key +\nsame body replays the original response (with `Idempotent-Replayed: true`)\ninstead of sending the reply to the platform again; same key + different\nbody returns 422; a key still in flight returns 409. Keys are retained for\n24 hours and are scoped to the credential and to this exact path, so\nreusing a key against a different reviewId returns 422 rather than\nreplaying the other review's response.\n\nOnly successful (2xx) responses are stored for replay. If the request\nthrows or returns a non-2xx status the key is released, so the header\nprotects the \"request succeeded but the response was lost\" case. After an\nambiguous failure (a 5xx or a network timeout) fetch the review before\nretrying with the same key, and treat a missing reply as inconclusive\nrather than as proof nothing was sent.\n","tags":["Reviews"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"reviewId","in":"path","required":true,"schema":{"type":"string"},"description":"Review ID (URL-encoded for Google Business Profile)"},{"$ref":"#/components/parameters/IdempotencyKeyHeader"}],"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"}}}}}},"400":{"description":"Facebook refused the reply for this review (code platform_api_error, platform facebook, Meta error 12 echoed in platformError). Permanent for that review: Graph cannot address its underlying post, and it usually already carries a reply from the Page, which the review listing reports under hasReply. Do not retry.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Inbox addon required"},"409":{"$ref":"#/components/responses/IdempotencyKeyInFlight"},"422":{"$ref":"#/components/responses/IdempotencyKeyReused"}}},"delete":{"x-resource-group":"engagement","operationId":"deleteInboxReviewReply","summary":"Delete review reply","description":"Delete a reply to a review (Google Business Profile 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"}}}},"/v1/whatsapp/templates":{"get":{"x-resource-group":"accounts","operationId":"getWhatsAppTemplates","tags":["WhatsApp"],"summary":"List templates","description":"List message templates for the WhatsApp Business Account (WABA) associated with the given account.\nTemplates are fetched directly from the WhatsApp Cloud API. One entry per **name + language**:\na multi-language template appears once per language, each with its own Meta `id`.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"description":"WhatsApp account ID","schema":{"type":"string"}},{"name":"name","in":"query","required":false,"description":"Exact template name; returns every language variant of that family.","schema":{"type":"string"}},{"name":"language","in":"query","required":false,"description":"Exact language code (e.g. en_US).","schema":{"type":"string"}},{"name":"status","in":"query","required":false,"schema":{"type":"string","enum":["APPROVED","REJECTED","PENDING","PAUSED","DISABLED","IN_APPEAL","PENDING_DELETION"]}}],"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"},"message_send_ttl_seconds":{"type":"integer","description":"Only when a custom TTL is set; absent while the category default applies."},"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:\n\nCustom template: Provide components with your own content. Submitted to Meta for review (can take up to 24h).\n\nLibrary template: Provide library_template_name instead of components to use a pre-built template\nfrom Meta's template library. Library templates are pre-approved (no review wait). You can optionally\ncustomize parameters and buttons via library_template_body_inputs and library_template_button_inputs.\n\nBrowse available library templates at: https://business.facebook.com/wa/manage/message-templates/\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","name","category","language"],"properties":{"accountId":{"type":"string","description":"WhatsApp 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\",\n\"auto_pay_reminder_1\", \"address_update\"). When provided, the template is pre-approved\nby Meta with no review wait. Omit components when using this field.\n"},"library_template_body_inputs":{"type":"object","description":"Optional body customizations for library templates. Available options depend on the\ntemplate (e.g., add_contact_number, add_learn_more_link, add_security_recommendation,\nadd_track_package_link, code_expiration_minutes).\n"},"library_template_button_inputs":{"type":"array","description":"Optional button customizations for library templates. Each item specifies button type\nand configuration (e.g., URL, phone number, quick reply).\n","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"}}}},"message_send_ttl_seconds":{"type":"integer","description":"Delivery validity window in seconds: a message not delivered within it is dropped. Range depends on category: AUTHENTICATION 30 to 900, UTILITY 30 to 43200 (12h), MARKETING 43200 to 2592000 (30 days); -1 (create only) keeps the 30-day default on AUTHENTICATION and UTILITY. Meta defaults to 600 for AUTHENTICATION and 30 days otherwise. If Meta later recategorises the template, it clears the TTL (read it back to check)."}}},"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"},"message_send_ttl_seconds":{"type":"integer","description":"Echoed when supplied on the request."}}}}}}}},"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 one message template variant by name.\n\nMeta stores one template per **name + language**, so a name identifies a family of variants,\neach with its own Meta id. Pass `language` to address one variant. Without it, a name with a\nsingle variant resolves to that variant; a name with several returns `409 ambiguous_template`\nwith `details.languages`. A bare language (`es`) matches a single regional variant (`es_ES`);\nif the family has several regional variants for it, that is also a 409. A full code (`es_ES`)\nmust match exactly. Variants in `PENDING_DELETION` are not part of the family.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"templateName","in":"path","required":true,"description":"Template name (the family).","schema":{"type":"string"}},{"name":"accountId","in":"query","required":true,"description":"WhatsApp account ID","schema":{"type":"string"}},{"name":"language","in":"query","required":false,"description":"Language code of the variant (e.g. en_US, es, pt_BR). Required when the family has several languages.","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","description":"Meta template id. Unique per language variant; usable on /v1/whatsapp/templates/id/{templateId}."},"name":{"type":"string"},"status":{"type":"string"},"category":{"type":"string"},"language":{"type":"string","description":"The variant actually returned."},"components":{"type":"array","items":{"type":"object"}},"message_send_ttl_seconds":{"type":"integer","description":"Only when a custom TTL is set; absent while the category default applies."},"rejected_reason":{"type":"string","description":"Only when status is REJECTED."},"quality_score":{"type":"object","description":"Post-approval quality (GREEN/YELLOW/RED), when Meta reports one."}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Account not found, or no template with that name (and language, when given). details.languages lists the family's languages when the name exists (code template_not_found)."},"409":{"description":"The template name exists in several languages and no language was given (code ambiguous_template). details.languages lists them.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"type":{"type":"string","enum":["invalid_request_error"]},"code":{"type":"string","enum":["ambiguous_template"]},"param":{"type":"string","enum":["language"]},"details":{"type":"object","properties":{"languages":{"type":"array","items":{"type":"string"}}}}}}}}},"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 one variant's components and/or its message_send_ttl_seconds. Name, language and category cannot change after creation.\n\nMeta stores one template per **name + language**, so a name identifies a family of variants,\neach with its own Meta id. Pass `language` to address one variant. Without it, a name with a\nsingle variant resolves to that variant; a name with several returns `409 ambiguous_template`\nwith `details.languages`. A bare language (`es`) matches a single regional variant (`es_ES`);\nif the family has several regional variants for it, that is also a 409. A full code (`es_ES`)\nmust match exactly. Variants in `PENDING_DELETION` are not part of the family.\n\nMeta only allows editing templates in `APPROVED`, `REJECTED` or `PAUSED` state; an approved\ntemplate can be edited once per 24 hours and up to 10 times per 30 days. A component update\nsends the variant back to Meta for review, so the `status` returned here is normally `PENDING`;\na TTL-only update keeps an APPROVED variant approved.\nThe final outcome arrives on the `whatsapp.template.status_updated` webhook (which carries the\nvariant's `templateId` and `language`). A variant already in `PENDING` cannot be edited again\nuntil Meta finishes reviewing it.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"templateName","in":"path","required":true,"description":"Template name (the family).","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId"],"properties":{"accountId":{"type":"string","description":"WhatsApp account ID"},"language":{"type":"string","description":"Language code of the variant to edit (e.g. en_US, es, pt_BR). Required when the family has several languages. Body only: a language query parameter on PATCH is a 400."},"components":{"type":"array","description":"Updated template components. Optional when only message_send_ttl_seconds changes; at least one of the two is required.","minItems":1,"items":{"$ref":"#/components/schemas/WhatsAppTemplateComponent"}},"message_send_ttl_seconds":{"type":"integer","description":"Delivery validity window in seconds: a message not delivered within it is dropped. Range depends on category: AUTHENTICATION 30 to 900, UTILITY 30 to 43200 (12h), MARKETING 43200 to 2592000 (30 days); -1 is not accepted here (Meta treats it as an empty edit); send a value in range. A TTL-only edit keeps an APPROVED template approved, no re-review. Meta defaults to 600 for AUTHENTICATION and 30 days otherwise. If Meta later recategorises the template, it clears the TTL (read it back to check)."}}},"example":{"accountId":"507f1f77bcf86cd799439011","language":"es","components":[{"type":"body","text":"Actualizado: tu pedido {{1}} está confirmado. Entrega el {{2}}","example":{"body_text":[["ORD-12345","1 de abril"]]}},{"type":"buttons","buttons":[{"type":"quick_reply","text":"Seguir pedido"}]}]}}}},"responses":{"200":{"description":"Template updated successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"template":{"type":"object","properties":{"id":{"type":"string","description":"Meta id of the edited variant."},"name":{"type":"string"},"language":{"type":"string","description":"The variant that was edited."},"status":{"type":"string","description":"Approval state read back from Meta after the update, normally PENDING. If the state cannot be read back, the last known status is returned instead.","example":"PENDING"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Account not found, or no template with that name (and language, when given) (code template_not_found)."},"409":{"description":"The template name exists in several languages and no language was given (code ambiguous_template). details.languages lists them.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"type":{"type":"string","enum":["invalid_request_error"]},"code":{"type":"string","enum":["ambiguous_template"]},"param":{"type":"string","enum":["language"]},"details":{"type":"object","properties":{"languages":{"type":"array","items":{"type":"string"}}}}}}}}},"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.\n\n**Without `language` this deletes every language variant of the name** (Meta's own\ncontract for deletion by name). Pass `language` to delete one variant only; the response\n`scope` says which happened. Meta keeps a deleted approved template in `PENDING_DELETION`\nfor a while and the name cannot be reused for 30 days.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"templateName","in":"path","required":true,"description":"Template name (the family).","schema":{"type":"string"}},{"name":"accountId","in":"query","required":true,"description":"WhatsApp account ID","schema":{"type":"string"}},{"name":"language","in":"query","required":false,"description":"Delete only this language variant (e.g. es). Omit to delete the whole family.","schema":{"type":"string"}}],"responses":{"200":{"description":"Template deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"scope":{"type":"string","enum":["all_languages","language"],"description":"Whether the whole family or one variant was deleted."},"language":{"type":"string","description":"The deleted variant; only when scope is language."},"message":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Account not found, or (with language) no such variant (code template_not_found)."},"409":{"description":"Only with language: a bare code (es) matched several regional variants (es_ES, es_MX), so nothing was deleted (code ambiguous_template). Without language there is no 409: the whole family is deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"type":{"type":"string","enum":["invalid_request_error"]},"code":{"type":"string","enum":["ambiguous_template"]},"param":{"type":"string","enum":["language"]},"details":{"type":"object","properties":{"languages":{"type":"array","items":{"type":"string"}}}}}}}}},"502":{"description":"Meta rejected the request or was unreachable. Meta 4xx statuses are forwarded as-is."}}}},"/v1/whatsapp/templates/id/{templateId}":{"get":{"x-resource-group":"accounts","operationId":"getWhatsAppTemplateById","tags":["WhatsApp"],"summary":"Get template by id","description":"Retrieve one template variant by its Meta id, the id every variant of a family has on its own\nand the one the `whatsapp.template.status_updated` webhook carries.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"templateId","in":"path","required":true,"description":"Meta template id (numeric).","schema":{"type":"string"}},{"name":"accountId","in":"query","required":true,"description":"WhatsApp 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","description":"Meta template id. Unique per language variant; usable on /v1/whatsapp/templates/id/{templateId}."},"name":{"type":"string"},"status":{"type":"string"},"category":{"type":"string"},"language":{"type":"string","description":"The variant actually returned."},"components":{"type":"array","items":{"type":"object"}},"message_send_ttl_seconds":{"type":"integer","description":"Only when a custom TTL is set; absent while the category default applies."},"rejected_reason":{"type":"string","description":"Only when status is REJECTED."},"quality_score":{"type":"object","description":"Post-approval quality (GREEN/YELLOW/RED), when Meta reports one."}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Account not found."},"502":{"description":"Meta rejected the request (including an id the account cannot access) or was unreachable. Meta 4xx statuses are forwarded as-is."}}},"patch":{"x-resource-group":"accounts","operationId":"updateWhatsAppTemplateById","tags":["WhatsApp"],"summary":"Update template by id","description":"Update one variant's components and/or its message_send_ttl_seconds by its Meta id. Name, language and category cannot change.\n\nMeta only allows editing templates in `APPROVED`, `REJECTED` or `PAUSED` state; an approved\ntemplate can be edited once per 24 hours and up to 10 times per 30 days. A component update\nsends the variant back to Meta for review, so the `status` returned here is normally `PENDING`;\na TTL-only update keeps an APPROVED variant approved.\nThe final outcome arrives on the `whatsapp.template.status_updated` webhook (which carries the\nvariant's `templateId` and `language`). A variant already in `PENDING` cannot be edited again\nuntil Meta finishes reviewing it.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"templateId","in":"path","required":true,"description":"Meta template id (numeric).","schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId"],"properties":{"accountId":{"type":"string","description":"WhatsApp account ID"},"components":{"type":"array","description":"Updated template components. Optional when only message_send_ttl_seconds changes; at least one of the two is required.","minItems":1,"items":{"$ref":"#/components/schemas/WhatsAppTemplateComponent"}},"message_send_ttl_seconds":{"type":"integer","description":"Delivery validity window in seconds: a message not delivered within it is dropped. Range depends on category: AUTHENTICATION 30 to 900, UTILITY 30 to 43200 (12h), MARKETING 43200 to 2592000 (30 days); -1 is not accepted here (Meta treats it as an empty edit); send a value in range. A TTL-only edit keeps an APPROVED template approved, no re-review. Meta defaults to 600 for AUTHENTICATION and 30 days otherwise. If Meta later recategorises the template, it clears the TTL (read it back to check)."}}}}}},"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"},"language":{"type":"string"},"status":{"type":"string","description":"Approval state read back from Meta after the update, normally PENDING. If the state cannot be read back, the last known status is returned instead.","example":"PENDING"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Account not found."},"502":{"description":"Meta rejected the update (including an id the account cannot access) or was unreachable. Meta 4xx statuses are forwarded as-is."}}},"delete":{"x-resource-group":"accounts","operationId":"deleteWhatsAppTemplateById","tags":["WhatsApp"],"summary":"Delete template by id","description":"Delete one language variant by its Meta id. Other languages of the same name are untouched.\nThe name cannot be reused for 30 days once its last variant is deleted.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"templateId","in":"path","required":true,"description":"Meta template id (numeric).","schema":{"type":"string"}},{"name":"accountId","in":"query","required":true,"description":"WhatsApp account ID","schema":{"type":"string"}}],"responses":{"200":{"description":"Variant deleted successfully","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"},"scope":{"type":"string","enum":["language"]},"language":{"type":"string"},"message":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Account not found."},"502":{"description":"Meta rejected the request (including an id the account cannot access) or was unreachable. Meta 4xx statuses are forwarded as-is."}}}},"/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\nWhatsApp account: whether calling is enabled, the forward-to\ndestination URI, recording opt-in state, the phone number record id\n(use as `{id}` on the read-write calling sub-resource at\n/v1/phone-numbers/{id}/whatsapp/calling) and whether SIP digest\ncredentials are stored (the encrypted password itself is never\nreturned). Also carries account-level extras (billing eligibility,\ncurrent-period spend) that the number-keyed GET does not.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"WhatsApp 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\nintegrations should use that path.\n\nEnable WhatsApp Business Calling on a connected number. Configures\nMeta calling.status=ENABLED with our Telnyx SIP endpoint, fetches and\nstores the Meta-issued SIP password (encrypted), and snapshots the\ncustomer's forward-to destination.\n","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\nintegrations should use that path.\n\nUpdate fields on an already-enabled number. Only fields present in\nthe body are written; `undefined` leaves the stored value alone,\nexplicit `null` clears a nullable field. No Meta side effect, this\nonly changes local routing state consumed by the Telnyx webhook\nhandler.\n","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\nintegrations should use that path.\n\nDisable calling. Sends calling.status=DISABLED to Meta (best-effort)\nand flips the local `callingEnabled` flag off. forwardTo and SIP\ncreds are preserved so a re-enable does not lose the destination.\n","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\na given consumer wa_id (e.g. `start_call`, `send_call_permission_request`).\nUse this before placing a call to decide whether to prompt for\nconsent first.\n","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\nleg is originated server-side (Option B: SIP-first). Telnyx INVITEs\nMeta directly over TLS:5061 with the SIP digest credentials we\ncaptured at calling-enablement time). No client-side SDP is\nrequired; pass only `accountId` and `to`.\n\nTo send the consumer the call-consent prompt instead of placing a\ncall, pass `action: \"send_call_permission_request\"` (+ optional\n`bodyText`). The consumer must tap Allow in WhatsApp before\n`start_call` is permitted; Meta limits the prompt to 1 per consumer\nper 24h (2 per 7 days) and requires an open 24h service window.\n\n**Idempotency:** send an `Idempotency-Key` header to make retries\nsafe; same key + same body replays the original response instead of\ndialing (and billing) a second call.\n","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\nnumber's stored forwardTo (tel:+E164, sip:..., wss://...).\n"},"recordOverride":{"type":"boolean"},"biz_opaque_callback_data":{"type":"string","maxLength":512,"description":"Accepted for forward compatibility. Not currently echoed\nback in webhook payloads (SIP-first flow does not pass\nthrough Meta's Graph API where Meta would echo this).\n"}}}}}},"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\nscoped to the resolved SocialAccount; profile-scoped team members\ncannot read calls on sibling accounts.\n\nCursor pagination: pass the returned `nextCursor` as `before` to fetch\nthe next page (same scheme as `GET /v1/calls`). `since`/`until` remain\nas absolute range filters and combine with the cursor.\n","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.\nProvider-signed recording URLs expire ~10 minutes after signing, so the\n`recordingUrl` stored on the call is usually stale by the time it is\nplayed; this endpoint re-signs on demand. Default responds `302 Found`\nredirecting to the fresh URL (point an `<audio>` element or a link\nstraight at this endpoint); pass `as=json` to receive `{ url }` instead.\n","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":{"200":{"description":"Recording URL (`as=json` only).","content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string"}}}}}},"302":{"description":"Redirect to a freshly-signed recording URL."},"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\ngiven destination, broken down by Meta + Telnyx + recording line\nitems. Costs are pass-through, no margin applied.\n","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\n(WhatsApp Business Calling + regular phone/PSTN), inbound and outbound,\nnewest first. Unlike `GET /v1/voice/calls` (PSTN-only) and\n`GET /v1/whatsapp/calls` (one account at a time), this endpoint needs no\n`accountId` and never requires fanning out one request per number.\n\nAny row can be opened channel-agnostically via `GET /v1/calls/{id}` and\n`GET /v1/calls/{id}/recording`; no branching on `channel` needed. When\nthe counterparty number matches a CRM contact, `contactId` and\n`contactName` are set.\n\nCursor pagination: pass the returned `nextCursor` as `before` to fetch\nthe next page. `nextCursor` is null on the last page.\n","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"}}}},"/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\nphone (PSTN) calls, so any row from `GET /v1/calls` can be opened\nwithout branching on `channel`. Returns the full call including\ntranscript segments, with `contactId`/`contactName` set when the\ncounterparty matches a CRM contact.\n","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"},"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\nfor any call regardless of channel (provider-signed URLs expire ~10\nminutes after signing, so this re-signs on demand). Default responds\n`302 Found` redirecting to the fresh URL; pass `as=json` to receive\n`{ url }` instead.\n","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":{"200":{"description":"Recording URL (`as=json` only).","content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string"}}}}}},"302":{"description":"Redirect to a freshly-signed recording URL."},"400":{"$ref":"#/components/responses/BadRequest"},"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":{"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,\nbridges the callee to the number's stored forward destination, or to\nthe per-call `forwardTo` override. Destinations can be your own AI\nvoice agent (Vapi/Retell), a phone, or a SIP endpoint. An optional\n`greeting` is spoken to the callee before the bridge.\n\nThe 200 response means the call is dialing; the lifecycle continues\nasynchronously (track it via `GET /v1/voice/calls/{id}` or the `call.*`\nwebhooks). Outbound calls are capped per rolling hour (429 when hit).\n\n**Idempotency:** send an `Idempotency-Key` header to make retries safe;\nsame key + same body replays the original response instead of dialing\n(and billing) a second call.\n","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\npagination: pass the returned `nextCursor` as `before` for the next\npage. For a history that also includes WhatsApp calls, use\n`GET /v1/calls`.\n","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\nended (or never connected) returns success with the call's current\nstatus. Final duration/cost are written asynchronously when the hangup\nevent lands, so the call doc may briefly still show its prior status.\n","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\n(provider-signed URLs expire ~10 minutes after signing, so this\nendpoint re-signs on demand). Default responds `302 Found` redirecting\nto the fresh URL; pass `as=json` to receive `{ url }` instead.\n","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":{"200":{"description":"Recording URL (`as=json` only).","content":{"application/json":{"schema":{"type":"object","properties":{"url":{"type":"string"}}}}}},"302":{"description":"Redirect to a freshly-signed recording URL."},"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\nSIP endpoint). This is a BLIND transfer: control of the leg is handed\noff and the call ends normally when the transferred leg hangs up. The\ncaller ID presented on the transfer leg is always your own number.\n","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\nrecording and transcription add-ons. Same billing formula as the\npost-call invoice, so the quote and the final charge can't disagree.\nThe per-minute figure is deliberately conservative (the real cost\ncomes from the settled carrier record after the call), so estimates\ntrend slightly over the actual invoice. Parity endpoint of\n`GET /v1/whatsapp/calls/estimate`, minus the Meta line (PSTN calls\nhave no separate Meta bill, so `totalCostUSD` equals\n`billableCostUSD`).\n","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\nsession (token + credential) the browser registers with the\n`@telnyx/webrtc` SDK. Once registered, call\n`POST /v1/voice/calls/web/dial` with the returned `credentialId` to\nplace the call. The split avoids bridging to a browser that has not\nfinished registering. The token lives ~1 hour (it must outlive the\nwhole call, not only the handshake).\n","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\nwhose answered leg is bridged to the browser registered with the\ncredential from `POST /v1/voice/calls/web`. The call runs through the\nnormal outbound lane, so it is logged as outbound (from = your number,\nto = target) and recorded per the number's settings.\n","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\nSMS-enabled numbers. At least one of `text` / `mediaUrls` is required.\nBoth numbers are normalized to E.164, so `from` matches regardless of\nformatting and replies thread into the same inbox conversation.\n\nUS numbers must have an approved carrier registration\n(`/v1/sms/registrations`) before messages deliver.\n\n**Replies and delivery status arrive as webhooks**, not by polling:\nan inbound reply fires `message.received` with `platform: \"sms\"`, the\nfirst message of a new thread also fires `conversation.started`, and\nthis message's own outcome fires `message.delivered` or\n`message.failed` (the latter carrying the carrier's error code).\n\n**Opted-out recipients:** a send to a number that replied STOP is\nrefused with `409`, never silently dropped.\n\n**Idempotency:** send an `Idempotency-Key` header to make retries safe:\nsame key + same body replays the original response instead of sending a\nsecond message; same key + different body returns 422; a key still in\nflight returns 409.\n","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"]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No SMS-enabled number matches `from`"},"409":{"description":"Recipient has opted out (replied STOP), or the same Idempotency-Key is still in flight"},"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\nnumber, plus `smsReachable` (landlines can't receive SMS). Use it to\nvalidate recipients before sending. Each lookup is billed by the\ncarrier-data provider, so call it explicitly (e.g. pre-validating an\nopt-in list), not on every send.\n","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,\nmost recent first. Compliance surface: you must be able to see and\nexport your opt-out list. Read-only: a recipient is re-subscribed only\nby replying START. Pass `format=csv` to download a CSV instead of JSON.\n","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`\nfor one-way international SMS. No phone number purchase or carrier\nregistration is needed; once created, pass it as `from` on\n`POST /v1/sms/messages`.\n\nConstraints: 3-11 characters (letters, digits, spaces; at least one\nletter). Sends cannot reach the US, Canada, or Puerto Rico, are\ntext-only, and recipients cannot reply. Sender IDs that impersonate\nwell-known brands or institutions are rejected. Names are not\nexclusive: the same sender ID can be registered by any number of\nteams. Creating the same sender ID again is a no-op\n(re-activates it after a delete).\n","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"},"402":{"description":"No payment method on file (code `payment_required`). Sender-ID sends incur carrier fees, so the billing owner needs a card before one can be created."},"403":{"description":"The team 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)."},"409":{"description":"Billing setup is incomplete for this team (code `billing_setup_incomplete`); contact 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 team'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":"Team-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 team's daily sender-ID message cap.\nThere is no self-serve raise: the request (desired cap + use case) is\nreviewed manually, usually within a business day.\n","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\nsame sender ID via `POST /v1/sms/sender-ids` re-activates it.\n","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\ndelivers: 10DLC (standard company or sole-proprietor) or toll-free\nverification. 10DLC needs `brand` + `campaign`; toll-free needs\n`tollFree`. Approval is asynchronous; poll\n`GET /v1/sms/registrations/{id}` (sole-prop registrations first need\nthe OTP step: a code is texted to the brand's mobile number, submit it\nvia `/verify-otp`).\n\nAlready have an approved registration? Add another number to it with\n`POST /v1/phone-numbers/{id}/sms/reuse-registration` instead\nof registering (and paying the carrier brand fee) again.\n\nRather have your client fill in the legal business details? Create a\nshare link with `POST /v1/sms/registrations/share`.\n","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.\nThe opt-in/opt-out/help auto-responses (`optinMessage`,\n`optoutMessage`, `helpMessage`) are optional: when omitted, a\ncompliant, brand-named template with the carrier-required\ndisclosures is generated for you. If you do send them, they must\nname the registered brand and carry the disclosures. Submissions\nthat don't are rewritten to the compliant template before the\ncampaign is filed.\n","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\nregistry requires 2-5, and reviewers match them against the sample\nmessages). Omitted: a default pair is applied for MIXED.\n"},"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, because 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\ncomposes the exact brand/campaign payloads a submission would store\n(branding, disclosures, auto-replies), runs deterministic compliance\nlints plus an AI reviewer over them, and returns the findings WITHOUT\ncreating anything. Use it to fix issues before submitting; `block`\nseverity findings indicate a near-certain carrier rejection.\n","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\nmonthly campaign fee stops (carriers bill the first 3 months of a\ncampaign regardless). Numbers covered by it can no longer SEND texts\n(receiving is unaffected) until they're registered under a new brand.\nIrreversible: a deactivated campaign cannot be restored; texting again\nlater requires a new registration (new one-time and review fees).\nIdempotent.\n","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\nregistrations with a campaign. Edit and resubmit it via the\nappeal endpoint's optional content fields.\n","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\none-time PIN texted to the brand's mobile number. On success the\nregistration continues to campaign creation automatically.\n","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\nnumber. Use it when the original code expired or never arrived. Only\nvalid while the registration is pending and awaiting its OTP; rate\nlimited to one send per minute.\n","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 sent recently. 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\nregistration that reached campaign creation can be appealed; a\nbrand-level rejection should be fixed and re-verified instead. On\nsuccess the registration returns to `pending`.\n\nContent rejections (e.g. an opt-in flow without a verifiable form link,\nor unrealistic samples) should be FIXED in the same call: pass the\ncorrected `messageFlow` / `sample1` / `sample2` and the campaign is\nupdated before the appeal is filed, so the reviewer sees the new\ncontent. The current content is on `GET /v1/sms/registrations/{id}`\n(`campaignContent`).\n","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\n`changes_requested` state: a note, hosted document URLs (from\n`POST /v1/sms/opt-in-proof`), or both, sent together. The registration\nreturns to `requested` (back in review), and you do not need to resubmit the\nwhole registration. To change the submitted brand/campaign fields\nthemselves, resubmit via `POST /v1/sms/registrations` with\n`resubmitRequestId` instead.\n","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\npublic URL. Include that URL in the campaign's `messageFlow` (the\nopt-in workflow text). The carrier registry has no attachment field,\nso reviewers verify consent by opening links in that answer. Works\nbefore a registration exists (use it when registering) and for\nappeals. `/v1/sms/registrations/{id}/opt-in-proof` is an alias.\n","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\npublic URL. Carrier reviewers reject campaigns whose consent can't be\nverified and ask for a \"link/screenshot of the opt-in form\". The\nregistry has no attachment field, so include the returned URL inside\nthe `messageFlow` you submit with the appeal\n(`POST /v1/sms/registrations/{id}/appeal`).\n","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\nelse (whoever has the legal business details) fill in the carrier\nregistration form for one of your numbers, without a Zernio login. The\nregistration is created under your account once the form is submitted.\n","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\nintrospect its structure before importing it. Most importantly it returns the\ntemplate's `buttons`: a library template with `URL` / `PHONE_NUMBER` buttons\nmust be created with a matching `library_template_button_inputs` array (see\nCreate Template), or Meta rejects it. Use this to discover which inputs to collect.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"WhatsApp account ID"},{"name":"name","in":"query","required":true,"schema":{"type":"string"},"description":"Exact library template name"},{"name":"language","in":"query","required":false,"schema":{"type":"string"},"description":"Desired language variant (e.g. es, en_US). If the template is not offered in it, the first available variant is returned and named in the response language field."}],"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"}},"availableLanguages":{"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"}}}},"/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.).\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"description":"WhatsApp 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.\nConstraints: about max 139 chars, description max 512 chars, max 2 websites.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId"],"properties":{"accountId":{"type":"string","description":"WhatsApp 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.\nUses Meta's resumable upload API under the hood: creates an upload session,\nuploads the image bytes, then updates the business profile with the resulting handle.\n\nProvide the image either as a binary upload (`multipart/form-data` with `file`)\nor as a download URL (`application/json` with `url`). With a URL we fetch the\nimage server-side and upload the bytes for you. Meta's profile-photo API is\nbytes-only, so there is no direct URL passthrough. JPEG/PNG, max 5MB either way.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"multipart/form-data":{"schema":{"type":"object","required":["accountId","file"],"properties":{"accountId":{"type":"string","description":"WhatsApp 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 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.\nDisplay name changes require Meta approval and can take 1-3 business days.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"description":"WhatsApp 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.\nThe new name must follow WhatsApp naming guidelines (3-512 characters, must represent your business).\nChanges require Meta review and approval, which typically takes 1-3 business days.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","displayName"],"properties":{"accountId":{"type":"string","description":"WhatsApp 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.\nUsername status can be `approved` (active), `reserved` (pending activation), or `none` (no username set).\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"description":"WhatsApp 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.\n\nUsername rules: 3-35 characters, letters/digits/period/underscore only, must contain at least one letter,\nno leading or trailing periods, no consecutive periods, no `www` prefix, no domain TLD suffix (e.g. `.com`).\n\nIf the desired username is currently held by another account, pass `transferAction: \"force_transfer\"` to\nrequest a transfer. On failure the API returns a standard error envelope with one of these codes:\n`whatsapp_username_unavailable` (already taken and transfer not requested),\n`whatsapp_username_ineligible` (account not eligible to claim a username), or\n`whatsapp_username_transfer_required` (username is held elsewhere; retry with `force_transfer`).\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","username"],"properties":{"accountId":{"type":"string","description":"WhatsApp 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.\n"},"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.\nAfter deletion the username becomes available for other accounts to claim.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId"],"properties":{"accountId":{"type":"string","description":"WhatsApp 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\nbusiness profile name. Use these to help users discover valid, unclaimed usernames.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"description":"WhatsApp 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\n(display number, display name + approval, quality rating, messaging-limit tier,\nthroughput, official-business badge, connection status, health_status) and its\nowning WhatsApp Business Account (name, business verification, timezone,\nhealth_status). Fetched live because Meta updates quality/tier/name/health over\ntime; the call also refreshes the cached values shown on the connection card.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"WhatsApp 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\nmembership endpoint, so this reads Zernio's blocklist mirror (kept in\nsync by the block/unblock endpoints; the first call per account\nbackfills the mirror from Meta's full list). Constant-time regardless\nof blocklist size.\n","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\n`nextCursor` back as `after` to fetch the next page. The blocklist holds\nup to 64,000 users.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"WhatsApp 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\nmessage your number or see that you are online, and your sends to them\nreturn an error.\n\nMeta constraints, surfaced per-user in `failed` (the request itself still\nsucceeds for the rest of the batch):\n- Only users who messaged your business within the last 24 hours can be\n  blocked (failures outside the window report \"Re-engagement required\").\n- Up to 1,000 users per request; the blocklist caps at 64,000.\n- Other WhatsApp Business accounts cannot be blocked.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","users"],"properties":{"accountId":{"type":"string","description":"WhatsApp 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.\nUp to 1,000 users per request; per-user failures are reported in\n`failed` without failing the rest of the batch.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","users"],"properties":{"accountId":{"type":"string","description":"WhatsApp 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/account-events":{"get":{"x-resource-group":"accounts","operationId":"listWhatsAppAccountEvents","tags":["WhatsApp"],"summary":"List account notifications","description":"Returns Meta-originated events recorded for a WhatsApp account, newest\nfirst: template review outcomes (approved, rejected, paused, category\nchanges) and WABA status changes (restricted, disabled, reinstated,\ndisconnected). Events are captured from Meta webhooks as they happen;\nthe feed starts at the account's first recorded event and is not\nbackfilled. Complements the push events `whatsapp.template.status_updated`\nand `account.disconnected` with a pollable history.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"WhatsApp account ID"},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":200,"default":50},"description":"Maximum events to return"}],"responses":{"200":{"description":"Recorded events, newest first","content":{"application/json":{"schema":{"type":"object","properties":{"events":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"accountId":{"type":"string","description":"WhatsApp account the event belongs to"},"type":{"type":"string","description":"Event kind, e.g. template_approved, template_rejected, account_restricted, account_disconnected"},"severity":{"type":"string","enum":["info","success","warning","critical"]},"title":{"type":"string"},"detail":{"type":["string","null"]},"createdAt":{"type":"string","format":"date-time"}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"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\nto the WhatsApp account, if one has been provisioned. Reads only from\nthe stored `metadata.metaCapiDatasetId`, never hits Meta, never\ncreates a dataset. Use this to detect whether `POST /v1/whatsapp/conversions`\nis configured for an account.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"WhatsApp 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\nClick-to-WhatsApp ad events are reported against via the Conversions\nAPI, and persists its ID on the account as `metadata.metaCapiDatasetId`.\n\nThe call is GET-first idempotent: a WABA can only own one CTWA\ndataset, so a second call after a successful provision is a safe no-op\nthat returns the same ID with `created: false`.\n\nRequires the connected WhatsApp account's token to carry the\n`whatsapp_business_manage_events` permission. If the permission is\nmissing the endpoint returns 422 with a message asking the user to\nreconnect the account.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId"],"properties":{"accountId":{"type":"string","description":"WhatsApp 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"}}}},"/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.\nBy default, released numbers are excluded. Connected (bring-your-own)\nWhatsApp numbers are returned in the separate `connected` array; they\nare not billed and have no provisioning lifecycle.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"status","in":"query","required":false,"description":"Filter by status (by default excludes released numbers). NOTE:\n`status=pending_regulatory` returns the \"provisioning\" view: numbers\nstill in review PLUS recently-declined (last 30 days) ones, so a\nfailed registration surfaces (with `regulatoryDeclineReason`) instead\nof silently disappearing. Declined numbers can be re-submitted via\nPOST /v1/phone-numbers/{id}/remediate. `verifying` is the\nshort-lived state after the number is provisioned on our side while\nWhatsApp confirms the activation code; the number is not billed until\nit reaches `active`.\n","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":"What this number bills each month, in cents. Stamped when the number was bought, so an existing number keeps its price when the rate card changes."},"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."},"sipTrunkId":{"type":["string","null"],"description":"SIP trunk the number is attached to; null when not trunked. While attached, enabling Calls or WhatsApp calling, requesting WhatsApp verification, and releasing the number all return 409."},"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."},"callingEnabled":{"type":"boolean","description":"Whether WhatsApp Business Calling is enabled on this number (manage via /v1/whatsapp/phone-numbers/{id}/calling)."},"createdAt":{"type":"string","format":"date-time"}}}},"connected":{"type":"array","description":"Connected (bring-your-own) WhatsApp numbers: your own WABA\nnumbers linked via Embedded Signup. Not provisioned or billed\nby Zernio, so they are not in `numbers`; `accountId` is the\nsocial-account id used by the messaging and inbox endpoints.\nIncluded only on the default and `status=active` views.\n","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"},"callingEnabled":{"type":"boolean","description":"Whether WhatsApp Business Calling is enabled on this number."}}}},"sandbox":{"type":["object","null"],"description":"The shared WhatsApp sandbox (one Zernio-owned number, all users test\nagainst it). Present when the sandbox is configured; null otherwise.\nThe `accountId` lets you address the sandbox in compose endpoints.\n`template` is the only template a sandbox send is allowed to use.\n","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\ntrack Meta pre-verification (US sync path) and, for regulated (Tier 3/4)\nnumbers, the async lifecycle: pending_regulatory → active (or\nregulatory_declined). When a regulated number has an Onfido ID step,\n`onfidoVerificationUrl` appears here once the order is placed. Forward\nit to the end user. (Or subscribe to the whatsapp.number.* webhooks\ninstead of polling.)\n","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"},"sipTrunkId":{"type":["string","null"],"description":"SIP trunk the number is attached to; null when not trunked. While attached, enabling Calls or WhatsApp calling, requesting WhatsApp verification, and releasing the number all return 409."}}}}}}}},"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:\n1. Disconnect any linked WhatsApp account\n2. Decrement the Stripe subscription quantity (or cancel if last number)\n3. Release the number from Telnyx\n4. Mark the number as released\n","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"},"409":{"description":"The number is attached to a SIP trunk; detach it first (code invalid_resource_state)."}}}},"/v1/phone-numbers/purchase":{"post":{"x-resource-group":"telephony","operationId":"purchasePhoneNumber","tags":["Phone Numbers"],"summary":"Purchase phone number","description":"Payment-first: the system provisions a number and auto-assigns it, unless you pass\n`phoneNumber` to buy one exact number from `GET /v1/phone-numbers/available`. With\nusage-based billing active and a payment method on file, the\nnumber provisions inline and bills per month on your usage-based invoice (there is\nno checkout redirect). No payment method on file returns `402 PAYMENT_REQUIRED`;\na regulated country returns `202` with `status: \"kyc_required\"` and a `kycUrl`.\n\nThe monthly price is the one `GET /v1/phone-numbers/countries` quotes for that\ncountry and `numberType` at the time of purchase, and it is stamped on the number:\nlater rate-card changes never move a number you already own.\n\nRequires usage-based billing (the Usage plan). The maximum number of phone numbers\nis determined by the user's plan.\n","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.\n"},"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.\n","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.\n"},"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).\n"},"phoneNumber":{"type":"string","pattern":"^\\+[1-9]\\d{6,14}$","description":"One exact number to buy, in E.164, taken from GET /v1/phone-numbers/available. Hard constraint: when it is no longer available (bought by someone else, or WhatsApp's buy-time check rejects it) the purchase fails with 409 code PHONE_NUMBER_UNAVAILABLE instead of assigning another number; search again and pick another. Only for countries and types that activate instantly: a regulated one (202 kyc_required) returns 400 when phoneNumber is set.\n"},"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.\n"},"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).\n"},"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.\n"},"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.\n"},"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).\n"}}},"example":{"profileId":"507f1f77bcf86cd799439011","country":"DE"}}}},"responses":{"200":{"description":"Either a checkout URL (first number) or the provisioned phone number (subsequent numbers).\n","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.\n","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."}}}]}}}},"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"}}}}}},"400":{"description":"Plan limit reached, profileId required, or country not available"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"Payment method required (usage-based billing account with no card on file). Response body carries code: PAYMENT_REQUIRED; add a card, then retry."},"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; PHONE_NUMBER_UNAVAILABLE: search again and pick another number).\n","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"code":{"type":"string","enum":["PURCHASE_VELOCITY","AREA_CODE_UNAVAILABLE","PHONE_NUMBER_UNAVAILABLE"]}}}}}},"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\nmonthly price (cents), regulatory tier, whether it needs end-user KYC\n(Tier 3/4), and per-feature availability (PSTN calls, WhatsApp, SMS,\nand WhatsApp Business Calling outbound). Drives the country picker.\nTier-4 countries appear only when enabled.\n","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","description":"Price a NEW number in this country costs per month, in cents, for the default (first) type. 1.5x the carrier's monthly cost, rounded up to a whole dollar, minimum $3."},"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."},"preOrderable":{"type":"boolean","description":"At least one out-of-stock type here can be pre-ordered (see `types[].preOrderable`)."},"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.\n","items":{"type":"object","properties":{"numberType":{"type":"string","enum":["local","mobile","national","toll_free"]},"tier":{"type":["integer","null"],"enum":[1,2,3,4,null],"description":"Null on a `fulfilment: request` type, whose document tier is only known once its requirements are read."},"needsKyc":{"type":"boolean"},"monthlyCents":{"type":"integer","description":"Price a NEW number of this type costs per month, in cents."},"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"},"fulfilment":{"type":"string","enum":["instant","request"],"description":"`request`: the carrier stocks this type nowhere and only sources it to order, so it is always a pre-order."},"preOrderable":{"type":"boolean","description":"Out of stock but orderable anyway. Submit KYC as usual (POST /v1/phone-numbers/kyc): we buy regular stock the moment it returns, otherwise the carrier sources the number. Usually 2 to 4 weeks, never guaranteed. Only document tiers (3/4) qualify, and nothing is billed until the number is active."}}}}}}}}}}}},"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\ncountry (default US). Optional filters narrow the results. The country\nmust be offerable (see GET /v1/phone-numbers/countries). Voice\ncapability is always required; pass `sms=true` to only see numbers that\ncan also text (SMS support is per-number, not per-country). Numbers a\npurchase would refuse are left out, and any result's `phoneNumber` can\nbe bought exactly by passing it to POST /v1/phone-numbers/purchase.\n","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","description":"E.164. Pass it as `phoneNumber` on POST /v1/phone-numbers/purchase to buy this exact number."},"features":{"type":"array","items":{"type":"string"},"description":"Provider capability list for this number (e.g. voice, sms, mms)."},"locality":{"type":"string","description":"Town or rate center the number belongs to, as the carrier names it (e.g. WACO)."},"bestEffort":{"type":"boolean","description":"true when the carrier added this number because too few matched your filters, so it may be outside the requested prefix or locality."}}}}}}}}},"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\n(regulated review is async, 1-3 days). Tells you whether we have\ndeliverable inventory, and what address the customer needs:\n  - `addressConstraint: geo`  → the registered address MUST be in one of\n    the returned `areas` (the only place we have stock). A different-area\n    address passes pre-approval but the number can never be assigned.\n  - `addressConstraint: country` → any in-country address works.\n  - `addressConstraint: none` → field-only / instant country, no address.\nCall this before starting the KYC form for regulated countries.\n","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."},"preOrderable":{"type":"boolean","description":"Nothing deliverable now, but this pair can be pre-ordered: submit KYC as usual and we buy regular stock the moment it returns, otherwise the carrier sources the number (usually 2 to 4 weeks, never guaranteed). Only document tiers (3/4) qualify."},"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.\n","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."}}}},"soldOutAreas":{"type":"array","description":"Areas that had stock in the last 90 days and have none now. Pass one as `areaCode` with `preOrder: true` on the KYC submit when `preOrderable` is true, or watch it with POST /v1/phone-numbers/stock-watches.\n","items":{"type":"object","properties":{"ndc":{"type":"string","description":"Area code (national destination code)."},"name":{"type":"string","description":"Area name."},"preOrderable":{"type":"boolean","description":"Whether this area can be pre-ordered: the carrier sources a number in it (usually 2 to 4 weeks, never guaranteed)."}}}}}}}}},"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\nintegrations should use that path.\n\nList all WhatsApp phone numbers purchased by the authenticated user.\nBy default, released numbers are excluded. Connected (bring-your-own)\nnumbers are returned in the separate `connected` array. They are not\nbilled and have no provisioning lifecycle.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"status","in":"query","required":false,"description":"Filter by status (by default excludes released numbers). NOTE:\n`status=pending_regulatory` returns the \"provisioning\" view: numbers\nstill in review PLUS recently-declined (last 30 days) ones, so a\nfailed registration surfaces (with `regulatoryDeclineReason`) instead\nof silently disappearing. Declined numbers can be re-submitted via\nPOST /v1/whatsapp/phone-numbers/{id}/remediate. `verifying` is the\nshort-lived state after the number is provisioned on our side while\nWhatsApp confirms the activation code; the number is not billed until\nit reaches `active`.\n","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":"What this number bills each month, in cents. Stamped when the number was bought, so an existing number keeps its price when the rate card changes."},"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."},"sipTrunkId":{"type":["string","null"],"description":"SIP trunk the number is attached to; null when not trunked. While attached, enabling Calls or WhatsApp calling, requesting WhatsApp verification, and releasing the number all return 409."},"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."},"callingEnabled":{"type":"boolean","description":"Whether WhatsApp Business Calling is enabled on this number (manage via /v1/whatsapp/phone-numbers/{id}/calling)."},"createdAt":{"type":"string","format":"date-time"}}}},"connected":{"type":"array","description":"Connected (bring-your-own) WhatsApp numbers: your own WABA\nnumbers linked via Embedded Signup. Not provisioned or billed\nby Zernio, so they are not in `numbers`; `accountId` is the\nsocial-account id used by the messaging and inbox endpoints.\nIncluded only on the default and `status=active` views.\n","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"},"callingEnabled":{"type":"boolean","description":"Whether WhatsApp Business Calling is enabled on this number."}}}},"sandbox":{"type":["object","null"],"description":"The shared WhatsApp sandbox (one Zernio-owned number, all users test\nagainst it). Present when the sandbox is configured; null otherwise.\nThe `accountId` lets you address the sandbox in compose endpoints.\n`template` is the only template a sandbox send is allowed to use.\n","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\nintegrations should use that path.\n\nPayment-first: the system provisions a number and auto-assigns it, unless you pass\n`phoneNumber` to buy one exact number from `GET /v1/phone-numbers/available`. With\nusage-based billing active and a payment method on file, the\nnumber provisions inline and bills per month on your usage-based invoice (there is\nno checkout redirect). No payment method on file returns `402 PAYMENT_REQUIRED`;\na regulated country returns `202` with `status: \"kyc_required\"` and a `kycUrl`.\n\nThe monthly price is the one `GET /v1/phone-numbers/countries` quotes for that\ncountry and `numberType` at the time of purchase, and it is stamped on the number:\nlater rate-card changes never move a number you already own.\n\nRequires usage-based billing (the Usage plan). The maximum number of phone numbers\nis determined by the user's plan.\n","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.\n","default":"US"},"phoneNumber":{"type":"string","pattern":"^\\+[1-9]\\d{6,14}$","description":"One exact number to buy, in E.164, taken from GET /v1/phone-numbers/available. Fails with 409 code PHONE_NUMBER_UNAVAILABLE when it is no longer available.\n"},"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.\n"},"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).\n"}}},"example":{"profileId":"507f1f77bcf86cd799439011","country":"DE"}}}},"responses":{"200":{"description":"Either a checkout URL (first number) or the provisioned phone number (subsequent numbers).\n","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.\n","properties":{"status":{"type":"string","enum":["already_purchased"]},"numberId":{"type":"string"},"phoneNumber":{"type":"string"}}}]}}}},"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"}}}}}},"400":{"description":"Plan limit reached, profileId required, or country not available"},"401":{"$ref":"#/components/responses/Unauthorized"},"402":{"description":"Payment method required (usage-based billing account with no card on file). Response body carries code: PAYMENT_REQUIRED; add a card, then retry."},"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; PHONE_NUMBER_UNAVAILABLE: search again and pick another number).\n","content":{"application/json":{"schema":{"type":"object","properties":{"error":{"type":"string"},"code":{"type":"string","enum":["PURCHASE_VELOCITY","AREA_CODE_UNAVAILABLE","PHONE_NUMBER_UNAVAILABLE"]}}}}}},"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\nintegrations should use that path.\n\nThe WhatsApp number countries available to purchase, each with its flat\nmonthly price (cents), regulatory tier, whether it needs end-user KYC\n(Tier 3/4), and whether outbound calling is available (not BIC-blocked).\nDrives the country picker. Tier-4 countries appear only when enabled.\n","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","description":"Price a NEW number in this country costs per month, in cents, for the default (first) type. 1.5x the carrier's monthly cost, rounded up to a whole dollar, minimum $3."},"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\nintegrations should use that path.\n\nSearch the provider's inventory for numbers available to purchase in a\ncountry (default US). Optional filters narrow the results. The country\nmust be offerable (see GET /v1/whatsapp/phone-numbers/countries).\n","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"},"locality":{"type":"string"},"bestEffort":{"type":"boolean"}}}}}}}}},"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\nintegrations should use that path.\n\nPre-purchase check, so you can warn BEFORE a customer invests in KYC\n(regulated review is async, 1-3 days). Tells you whether we have\ndeliverable inventory, and what address the customer needs:\n  - `addressConstraint: geo`  → the registered address MUST be in one of\n    the returned `areas` (the only place we have stock). A different-area\n    address passes pre-approval but the number can never be assigned.\n  - `addressConstraint: country` → any in-country address works.\n  - `addressConstraint: none` → field-only / instant country, no address.\nCall this before starting the KYC form for regulated countries.\n","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."},"preOrderable":{"type":"boolean","description":"Nothing deliverable now, but this pair can be pre-ordered: submit KYC as usual and we buy regular stock the moment it returns, otherwise the carrier sources the number (usually 2 to 4 weeks, never guaranteed). Only document tiers (3/4) qualify."},"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.\n","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."}}}},"soldOutAreas":{"type":"array","description":"Areas that had stock in the last 90 days and have none now. Pass one as `areaCode` with `preOrder: true` on the KYC submit when `preOrderable` is true, or watch it with POST /v1/phone-numbers/stock-watches.\n","items":{"type":"object","properties":{"ndc":{"type":"string","description":"Area code (national destination code)."},"name":{"type":"string","description":"Area name."},"preOrderable":{"type":"boolean","description":"Whether this area can be pre-ordered: the carrier sources a number in it (usually 2 to 4 weeks, never guaranteed)."}}}}}}}}},"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\nregulatory requirements) before a number can be ordered: text, date,\naddress, or file (document) per requirement.\n","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)."}}}}}}}}},"pendingReview":{"type":"boolean","description":"true when this account already has a number for this country in regulatory review (status pending_regulatory). Scope is the whole account across all profiles, and the country only (any number type), so it is not a per-end-client signal on a multi-tenant setup. Informational only: it never blocks a submission, and several same-country numbers may sit in review at once. For a per-end-client view, call GET /v1/phone-numbers with `profileId` and `status=pending_regulatory`; that view also lists numbers declined in the last 30 days."}}}}}},"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,\naddress) for a Tier 3/4 country. Documents are streamed straight to the\nnumber provider and are not stored by Zernio. Builds + submits a\nregulatory requirement group and claims a pending_regulatory slot; the\nnumber is ordered + activated once the provider approves (asynchronous).\nA customer may hold several same-country numbers in review at once; a\ndouble-submit of the SAME attempt is deduped via `submissionId`.\n\nFor an ID-card document requirement, carriers commonly require BOTH sides:\ncombine the front and back into a single file before uploading (the\ndashboard does this automatically). A one-sided ID is a common decline\nreason; fix it via POST /v1/phone-numbers/{id}/remediate.\n\nBefore submitting, call GET /v1/phone-numbers/availability to\ncheck the country has deliverable inventory and, for geographic-match\ncountries, which area the address must be in. Otherwise the submission\ncan pass review yet never be assignable a number.\n","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. Once the number has been ordered, a retry with the same token returns that same number instead of ordering another. A submission that fails before the number is ordered releases the token, so you can correct your details and re-submit with it. Omit it and every call provisions 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, so 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."},"preOrder":{"type":"boolean","description":"With areaCode: pre-order that area when it has no stock (an area listed in soldOutAreas with preOrderable true) instead of failing with AREA_CODE_UNAVAILABLE. The carrier sources a number in that area."},"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"},"extended_address":{"type":"string","description":"Address complement: apartment, suite, unit, or the quadra/lote used in some countries. Optional. Does not substitute for a building number on street_address."},"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"]},"preOrder":{"type":"boolean","description":"True when nothing was in stock and this submission placed a pre-order. The number stays `pending_regulatory` until we get it, from regular stock the moment it returns or sourced by the carrier (usually 2 to 4 weeks), and is not billed until active. Releasing it (DELETE /v1/phone-numbers/{id}) cancels the pre-order. A pre-order is one number: `quantity` above 1 is rejected with 400."},"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)"},"401":{"$ref":"#/components/responses/Unauthorized"},"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)."}}}},"/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`\nvalues from GET /v1/phone-numbers/kyc `reusable.options[].details[]`), so\nthe account holder can see what's on file before reusing it. Returned\ninline as `application/pdf` (uploads are normalized to PDF). Auth-scoped:\na document is viewable only when its id is referenced by one of the\ncaller's own numbers. Otherwise `404`.\n","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\nfrom POST /v1/phone-numbers/kyc via `documents[].documentId`.\nSend the RAW file bytes as the request body (not base64); put the filename\nin the `X-Filename` header. Uploading documents one-per-request keeps each\nrequest under the ~4.5MB body limit. The document streams straight to the\nnumber provider and is not stored by Zernio.\n","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)\nregistration: validates a postal address for deliverability BEFORE the full\nKYC submit, so it can be corrected before any documents are uploaded. The\nfull submit (POST /v1/phone-numbers/kyc) re-validates the address,\nso this call is purely a fast feedback path and skipping it is safe. Only\nthe postal address is sent (no documents, no gov-ID fields). A region\n(`administrative_area`) is required by the validator; when it is omitted the\npre-check is skipped and `{ ok: true, skipped: true }` is returned (the\nfinal submit still validates).\n","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"},"extended_address":{"type":"string","description":"Address complement: apartment, suite, unit, or the quadra/lote used in some countries. Optional. Does not substitute for a building number on street_address."},"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\nprovider returned usable corrections, `details.addressSuggestions` carries\nthem per field for a one-click \"apply suggestion\" card. (Flat error\nenvelope: `error` is the human message; `code`/`param`/`details` are\ntop-level siblings.)\n","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\ncompletes WITHOUT a Zernio login. Useful when the person who holds the\nID and address is not your team. They fill the regulated verification on\na Zernio-hosted page; the number provisions under YOUR account once they\nsubmit. Only regulated (KYC) countries are valid: a country that does not\nrequire KYC returns 400.\n\nWhite-label the page with `branding` (your company name, logo, brand\ncolor). Supply `redirect_url` to send the end customer back to your own\nsite after a successful submit (completion params are appended; see\nbelow). Listen for the `whatsapp.number.kyc_submitted` webhook to react\nwhen the form is completed.\n","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\nsubmit. On completion Zernio appends `kyc=submitted` and\n`country=<ISO-2>` as query params. When omitted, the hosted\npage shows a built-in confirmation screen instead.\n"}}}}}},"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.\nCreates the carrier order(s), attaches the end-user (current account)\ninfo plus the LOA and invoice documents, and submits to the losing\ncarrier. The transfer PIN is forwarded to the carrier and never stored.\nPorted numbers arrive voice-ready (and SMS-ready where the order\nsupports messaging).\n\nRun the portability check (POST /v1/phone-numbers/port-in/check) and\nupload the two documents (POST /v1/phone-numbers/port-in/documents)\nfirst. Uploaded documents must be attached to an order within 30\nminutes or the carrier deletes them, so upload right before this call.\nThe carrier may split the numbers into several orders (by country,\nnumber type, losing carrier); `orders` carries per-order results, and a\npartial failure still returns 201 with the failed orders' `error` set\n(they stay as cancellable drafts).\n\nNon-US/CA numbers additionally need the country-specific values from\nGET /v1/phone-numbers/port-in/requirements, passed via `requirements`,\nand must be submitted one country per request. When required\ninformation is still missing after submission, the order is kept as a\nresumable draft whose `error` / `declineReason` names the gaps.\n","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\nlosing carrier matches every field against its records and rejects the\nwhole port on a mismatch, so enter values exactly as they appear on the\ncarrier bill.\n","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, which 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:\npending, confirmed FOC date, exception reason, or ported.\n","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\nwhether it qualifies for FastPort, BEFORE the user commits to a port\norder (LOA, invoice, service address). Read-only; creates no order and\nbills nothing.\n","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\nsigned LOA / carrier invoice the id goes to `loaDocumentId` /\n`invoiceDocumentId`; for a country-specific document requirement\n(international ports) it becomes that requirement's `fieldValue`.\nRequirement documents are normalized to PDF automatically (regulators\nreject raw images). PDF, JPEG, or PNG, 10MB max. Uploads must be\nattached to an order within 30 minutes or the carrier deletes them.\n","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,\ninvoice, and account/address details, such as an ID copy, proof of\naddress, a tax id, or a porting code. Call it after the portability\ncheck (which returns each number's `countryCode` and\n`phoneNumberType`), render the fields, and pass the collected values as\nthe create request's `requirements`. US/CA return an empty list.\n","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,\nwhich are still pending, and which bounced on review\n(`requirement-info-exception`). Use it to fix and resubmit a rejected\ninternational port. Same field shape as the country-level requirements\nendpoint, plus per-requirement status.\n","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).\nOnly orders that haven't ported can be cancelled; a completed port is a\nnormal number release instead. The carrier may report `cancel-pending`\nbriefly while the losing carrier acknowledges; it settles to\n`cancelled`.\n","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\nthe exact documents the regulator will see (referenced by the ids from\nPOST /v1/phone-numbers/kyc/upload-document) against the declared values\nand address, and returns plain-language advisories for likely decline\nreasons (wrong document type, mismatched address, one-sided ID scans).\nNon-blocking: advisories are warnings, submitting anyway is always\nallowed, and any review failure degrades to an empty list.\n","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\nreviewer flagged declined, as a form spec (same shape as the KYC form GET).\nThe customer fixes only those, because Telnyx supports correcting a declined\nrequirement group and re-submitting it (no new number/group). Falls back\nto the full spec if the provider exposes no per-requirement flags.\n","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\nPATCH them onto the SAME requirement group and re-submit it for approval;\nthe number goes `regulatory_declined` → `pending_regulatory`. No new\nnumber and no new billing. Body shape matches the KYC submit (values /\ndocuments / address). Send only the corrected fields.\n","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\non a number awaiting remediation, for asks the structured form can't\nexpress (e.g. \"is this personal or business?\"). Attachments are stored by\nus and their links are added to the reviewer's comment thread (the\ncarrier's number order takes no loose files). A reply to a comment-style\nask moves the number back to \"in review\"; a reply on a formal decline is\nsupplementary and you must still resubmit the fix. Requires text or at\nleast one attachment.\n","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:\na free-text message and/or corrected requirement documents, in one call.\nIf corrections are present they are PATCHed onto the requirement group and\nre-submitted (the number goes back to \"in review\"); if a message or file\nattachments are present they are posted to the reviewer's comment thread.\nWhen both are present, your message is the thread comment and the resubmit\ndrives the state change. At least one of message, corrections, or\nattachments is required. `documents` correct requirement slots; `attachments`\nare loose files (their links are added to your message).\n","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\nintegrations should use that path.\n\nFor a Tier 3/4 country, the fields the end customer must provide (Telnyx\nregulatory requirements) before a number can be ordered: text, date,\naddress, or file (document) per requirement.\n","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)."}}}}}}}}},"pendingReview":{"type":"boolean","description":"true when this account already has a number for this country in regulatory review (status pending_regulatory). Scope is the whole account across all profiles, and the country only (any number type), so it is not a per-end-client signal on a multi-tenant setup. Informational only: it never blocks a submission, and several same-country numbers may sit in review at once. For a per-end-client view, call GET /v1/phone-numbers with `profileId` and `status=pending_regulatory`; that view also lists numbers declined in the last 30 days."}}}}}},"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\nintegrations should use that path.\n\nSubmit the end customer's KYC (textual values, uploaded documents,\naddress) for a Tier 3/4 country. Documents are streamed straight to the\nnumber provider and are not stored by Zernio. Builds + submits a\nregulatory requirement group and claims a pending_regulatory slot; the\nnumber is ordered + activated once the provider approves (asynchronous).\nA customer may hold several same-country numbers in review at once; a\ndouble-submit of the SAME attempt is deduped via `submissionId`.\n\nFor an ID-card document requirement, carriers commonly require BOTH sides:\ncombine the front and back into a single file before uploading (the\ndashboard does this automatically). A one-sided ID is a common decline\nreason; fix it via POST /v1/whatsapp/phone-numbers/{id}/remediate.\n\nBefore submitting, call GET /v1/whatsapp/phone-numbers/availability to\ncheck the country has deliverable inventory and, for geographic-match\ncountries, which area the address must be in. Otherwise the submission\ncan pass review yet never be assignable a number.\n","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. Once the number has been ordered, a retry with the same token returns that same number instead of ordering another. A submission that fails before the number is ordered releases the token, so you can correct your details and re-submit with it. Omit it and every call provisions 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, so 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."},"preOrder":{"type":"boolean","description":"With areaCode: pre-order that area when it has no stock (an area listed in soldOutAreas with preOrderable true) instead of failing with AREA_CODE_UNAVAILABLE. The carrier sources a number in that area."},"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"},"extended_address":{"type":"string","description":"Address complement: apartment, suite, unit, or the quadra/lote used in some countries. Optional. Does not substitute for a building number on street_address."},"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"]},"preOrder":{"type":"boolean","description":"True when nothing was in stock and this submission placed a pre-order. The number stays `pending_regulatory` until we get it, from regular stock the moment it returns or sourced by the carrier (usually 2 to 4 weeks), and is not billed until active. Releasing it (DELETE /v1/phone-numbers/{id}) cancels the pre-order. A pre-order is one number: `quantity` above 1 is rejected with 400."},"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)"},"401":{"$ref":"#/components/responses/Unauthorized"},"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)."}}}},"/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\nintegrations should use that path.\n\nUpload ONE document and get back its provider document id, to reference\nfrom POST /v1/whatsapp/phone-numbers/kyc via `documents[].documentId`.\nSend the RAW file bytes as the request body (not base64); put the filename\nin the `X-Filename` header. Uploading documents one-per-request keeps each\nrequest under the ~4.5MB body limit. The document streams straight to the\nnumber provider and is not stored by Zernio.\n","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\nintegrations should use that path.\n\nOptional early check for the address step of a Tier 4 (end-user identity)\nregistration: validates a postal address for deliverability BEFORE the full\nKYC submit, so it can be corrected before any documents are uploaded. The\nfull submit (POST /v1/whatsapp/phone-numbers/kyc) re-validates the address,\nso this call is purely a fast feedback path and skipping it is safe. Only\nthe postal address is sent (no documents, no gov-ID fields). A region\n(`administrative_area`) is required by the validator; when it is omitted the\npre-check is skipped and `{ ok: true, skipped: true }` is returned (the\nfinal submit still validates).\n","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"},"extended_address":{"type":"string","description":"Address complement: apartment, suite, unit, or the quadra/lote used in some countries. Optional. Does not substitute for a building number on street_address."},"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\nprovider returned usable corrections, `details.addressSuggestions` carries\nthem per field for a one-click \"apply suggestion\" card. (Flat error\nenvelope: `error` is the human message; `code`/`param`/`details` are\ntop-level siblings.)\n","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\nintegrations should use that path.\n\nCreate a single-use, 7-day hosted KYC link that your end customer\ncompletes WITHOUT a Zernio login. Useful when the person who holds the\nID and address is not your team. They fill the regulated verification on\na Zernio-hosted page; the number provisions under YOUR account once they\nsubmit. Only regulated (KYC) countries are valid: a country that does not\nrequire KYC returns 400.\n\nWhite-label the page with `branding` (your company name, logo, brand\ncolor). Supply `redirect_url` to send the end customer back to your own\nsite after a successful submit (completion params are appended; see\nbelow). Listen for the `whatsapp.number.kyc_submitted` webhook to react\nwhen the form is completed.\n","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\nsubmit. On completion Zernio appends `kyc=submitted` and\n`country=<ISO-2>` as query params. When omitted, the hosted\npage shows a built-in confirmation screen instead.\n"}}}}}},"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.\n\nA number is not a single record. Alongside the number itself there are\nhidden telephony owner accounts (platform `phone`, plus `sms` when SMS is\nenabled) and, once WhatsApp is connected, the `whatsapp` account. They all\ncarry a profileId and this endpoint moves them together.\n\nUse this instead of `PATCH /v1/accounts/{accountId}`: that one moves the\naccount only and leaves the number itself pinned to its original\nprofile, which splits the number across two profiles. Connecting a\nZernio-provisioned number from any profile but its own is rejected with a\n`409` (`WHATSAPP_NUMBER_PINNED_TO_PROFILE`). This endpoint is how you\nre-home the number first, so it can then be connected from the new profile.\n\n`id` is the number record id from `GET /v1/phone-numbers`, not an account id.\n\nA profile holds at most one account per platform, so the destination must be\nfree of every platform this number occupies.\n","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\nintegrations should use that path.\n\nFor a number in `regulatory_declined`, returns ONLY the requirements the\nreviewer flagged declined, as a form spec (same shape as the KYC form GET).\nThe customer fixes only those, because Telnyx supports correcting a declined\nrequirement group and re-submitting it (no new number/group). Falls back\nto the full spec if the provider exposes no per-requirement flags.\n","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\nintegrations should use that path.\n\nSubmit corrected values/documents for the declined requirement(s). We\nPATCH them onto the SAME requirement group and re-submit it for approval;\nthe number goes `regulatory_declined` → `pending_regulatory`. No new\nnumber and no new billing. Body shape matches the KYC submit (values /\ndocuments / address). Send only the corrected fields.\n","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\nconfigures how inbound calls are handled. Inbound calls route to\n`forwardTo`: your own AI voice agent (Vapi/Retell), a phone, or a SIP\nendpoint. Optional extras: voicemail, business-hours windows, an IVR\nmenu, a caller blocklist, recording, and transcription. A number can\nalso be voice-enabled with no forward (outbound-only).\n\nIdempotent, and doubles as the settings update: only fields present in\nthe body are written. Omitting `forwardTo` preserves the current\ndestination; sending an empty string clears it.\n","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"},"409":{"description":"This number is attached to a SIP trunk; detach it first (code invalid_resource_state)."},"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\nand settings are preserved, so re-enabling restores the prior config.\n","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/stock-watches":{"post":{"x-resource-group":"telephony","operationId":"createPhoneNumberStockWatch","tags":["Phone Numbers"],"summary":"Watch an out-of-stock country","description":"Get notified the first time an out-of-stock country has deliverable\nnumbers again: an email to the account holder plus the\n`phone_number.stock_available` webhook. Stock is re-checked every 6h.\nOne watch per country and number type; a repeat request returns the\nexisting watch (200). The watch is consumed when it fires, so re-create\nit if you miss the stock. Up to 20 watches at once.\n\nCountries and types marked `fulfilment: request` by\nGET /v1/phone-numbers/countries can also be watched, but anything with\n`preOrderable: true` does not need a watch: submit KYC and the carrier\nsources the number to order.\n\nPass `areaCode` (with `numberType`) to watch one sold-out area, for\nexample an entry of `soldOutAreas` from\nGET /v1/phone-numbers/availability. Area stock is checked live on the\nsame 6h cadence.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["country"],"properties":{"country":{"type":"string","minLength":2,"maxLength":2,"description":"ISO 3166-1 alpha-2 code of a country listed by GET /v1/phone-numbers/countries."},"numberType":{"type":"string","enum":["local","mobile","national","toll_free"],"description":"Narrow the watch to one number type. Omit to be notified when any type in the country is back."},"areaCode":{"type":"string","pattern":"^\\d{1,4}$","description":"Narrow the watch to one area code (NDC). Requires numberType."}}}}}},"responses":{"200":{"description":"A watch for this country and type already existed; returned unchanged.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberStockWatch"}}}},"201":{"description":"Watch created.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/PhoneNumberStockWatch"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"409":{"description":"The country (or the watched area) is in stock right now (buy instead of watching), or the 20-watch limit is reached (code invalid_resource_state)."}}},"get":{"x-resource-group":"telephony","operationId":"listPhoneNumberStockWatches","tags":["Phone Numbers"],"summary":"List stock watches","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The caller's active watches, oldest first.","content":{"application/json":{"schema":{"type":"object","properties":{"watches":{"type":"array","items":{"$ref":"#/components/schemas/PhoneNumberStockWatch"}}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/phone-numbers/stock-watches/{id}":{"delete":{"x-resource-group":"telephony","operationId":"deletePhoneNumberStockWatch","tags":["Phone Numbers"],"summary":"Stop watching a country","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Watch deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Stock watch not found (code stock_watch_not_found)."}}}},"/v1/phone-numbers/sip-trunks":{"post":{"x-resource-group":"telephony","operationId":"createSipTrunk","tags":["Voice"],"summary":"Create a SIP trunk","description":"Creates a SIP trunk an external voice platform (Retell, ElevenLabs,\nVapi, or any SIP endpoint) can import your Zernio numbers into. The\ntrunk carries both directions: inbound calls on attached numbers are\ndelivered to `sipHost`, and the platform originates outbound calls\nthrough `termination.uri` with the digest credentials.\n\nThe `digestPassword` is returned only by this call (and by\nrotate-credentials); store it immediately. Attach any number of numbers\nto a trunk. Several trunks may point at the same host. Each carries its\nown credentials and spend cap, so separate destinations (e.g.\nan agency's clients) stay isolated.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["label","sipHost"],"properties":{"label":{"type":"string","maxLength":80,"description":"Display name for the trunk."},"sipHost":{"type":"string","description":"Fully-qualified hostname inbound calls are delivered to (e.g. sip.rtc.elevenlabs.io, sip.retellai.com)."},"sipPort":{"type":"integer","minimum":1,"maximum":65535,"description":"Defaults to 5061 for tls, 5060 otherwise."},"transport":{"type":"string","enum":["tls","tcp","udp"],"description":"Signaling transport toward sipHost. Default tls (with SRTP media)."}}}}}},"responses":{"201":{"description":"Trunk created. The digest password is shown only here and on rotate.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string"},"sipHost":{"type":"string"},"sipPort":{"type":"integer"},"transport":{"type":"string","enum":["tls","tcp","udp"]},"termination":{"type":"object","properties":{"uri":{"type":"string","description":"Telnyx termination host the platform dials for outbound (sip.telnyx.com)."},"username":{"type":"string","description":"SIP digest username."}}},"numbersAttached":{"type":"integer"},"createdAt":{"type":["string","null"],"format":"date-time"},"digestPassword":{"type":"string","description":"SIP digest password, shown only in this response."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"SIP trunking is not enabled for this team, or the team is on legacy (non-usage-based) billing, which cannot invoice trunk call costs (code feature_not_available)."},"409":{"description":"The team trunk limit was reached (code invalid_resource_state)."},"422":{"description":"The host cannot be used as a trunk destination (e.g. a Zernio or carrier host)."}}},"get":{"x-resource-group":"telephony","operationId":"listSipTrunks","tags":["Voice"],"summary":"List SIP trunks","security":[{"bearerAuth":[]}],"responses":{"200":{"description":"The team's trunks. Passwords are never included.","content":{"application/json":{"schema":{"type":"object","properties":{"trunks":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string"},"sipHost":{"type":"string"},"sipPort":{"type":"integer"},"transport":{"type":"string","enum":["tls","tcp","udp"]},"termination":{"type":"object","properties":{"uri":{"type":"string"},"username":{"type":"string"}}},"numbersAttached":{"type":"integer"},"createdAt":{"type":["string","null"],"format":"date-time"}}}},"enabled":{"type":"boolean","description":"Whether this team can create SIP trunks. Managing existing trunks always works."}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"}}}},"/v1/phone-numbers/sip-trunks/{id}":{"get":{"x-resource-group":"telephony","operationId":"getSipTrunk","tags":["Voice"],"summary":"Get a SIP trunk","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Trunk detail, including the attached numbers.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string"},"sipHost":{"type":"string"},"sipPort":{"type":"integer"},"transport":{"type":"string","enum":["tls","tcp","udp"]},"termination":{"type":"object","properties":{"uri":{"type":"string"},"username":{"type":"string"}}},"numbersAttached":{"type":"integer"},"createdAt":{"type":["string","null"],"format":"date-time"},"numbers":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string","description":"Phone number record ID."},"phoneNumber":{"type":"string"}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"SIP trunk not found"}}},"delete":{"x-resource-group":"telephony","operationId":"deleteSipTrunk","tags":["Voice"],"summary":"Delete a SIP trunk","description":"Tears down the trunk and its carrier-side objects. Refused while any\nnumber is still attached: detach them first.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Trunk deleted.","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"SIP trunk not found"},"409":{"description":"Numbers are still attached to this trunk (code invalid_resource_state)."}}}},"/v1/phone-numbers/sip-trunks/{id}/rotate-credentials":{"post":{"x-resource-group":"telephony","operationId":"rotateSipTrunkCredentials","tags":["Voice"],"summary":"Rotate a SIP trunk's password","description":"Mints a new digest password on the trunk. The old password stops\nworking immediately, so update the destination platform right away.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"New credentials. The password is shown only here.","content":{"application/json":{"schema":{"type":"object","properties":{"termination":{"type":"object","properties":{"uri":{"type":"string","description":"Telnyx termination host the platform dials for outbound (sip.telnyx.com)."},"username":{"type":"string","description":"SIP digest username."}}},"digestPassword":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"SIP trunk not found"}}}},"/v1/phone-numbers/{id}/sip-trunk":{"post":{"x-resource-group":"telephony","operationId":"attachNumberToSipTrunk","tags":["Voice"],"summary":"Attach a number to a SIP trunk","description":"Routes the number's calls to the trunk: the external platform receives\nits inbound directly and can present it as outbound caller ID. While\nattached, Zernio-side voice features are off for this number (call\nforwarding, IVR, voicemail, recording, the softphone, and WhatsApp\ncalling), so the number must have Calls and WhatsApp calling disabled\nbefore attaching. SMS and WhatsApp messaging are unaffected.\n","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":["trunkId"],"properties":{"trunkId":{"type":"string","description":"SIP trunk ID (from POST /v1/phone-numbers/sip-trunks)."}}}}}},"responses":{"200":{"description":"Number attached (idempotent for the same trunk).","content":{"application/json":{"schema":{"type":"object","properties":{"attached":{"type":"boolean"},"phoneNumber":{"type":"string"},"trunkId":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"SIP trunking is not enabled for this team, or the team is on legacy (non-usage-based) billing, which cannot invoice trunk call costs (code feature_not_available)."},"404":{"description":"Number or trunk not found"},"409":{"description":"The number still has Calls or WhatsApp calling enabled, is mid WhatsApp verification, is not active, or is attached to another trunk (code invalid_resource_state)."},"422":{"description":"This number is hosted by your own carrier (brought via WhatsApp embedded signup), so it cannot be trunked."}}},"delete":{"x-resource-group":"telephony","operationId":"detachNumberFromSipTrunk","tags":["Voice"],"summary":"Detach a number from its SIP trunk","description":"Returns the number's calls to Zernio routing. Idempotent when the\nnumber is not attached to any trunk.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"id","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Number detached.","content":{"application/json":{"schema":{"type":"object","properties":{"attached":{"type":"boolean","description":"Always false after a successful detach."},"phoneNumber":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"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\ncapability is checked first: some number types can't do SMS at all\n(`smsCapable: false`), and a number still provisioning at the carrier\nreturns `notReady: true` (try again once provisioning finishes).\n\nUS numbers additionally need a carrier registration before messages\ndeliver; the response tells you which path applies:\n- `alreadyRegistered: true`: a prior registration still covers this\n  number; SMS was reactivated.\n- `reusable` set: you have an approved registration this number can\n  join in one click via\n  `POST /v1/phone-numbers/{id}/sms/reuse-registration`\n  (no new brand/campaign, no extra carrier fee).\n- `needsRegistration: true` and no `reusable`: start one via\n  `POST /v1/sms/registrations`.\n\nIdempotent: re-running re-attempts any carrier-side setup that failed.\n","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 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\nregistration is untouched, so re-enabling later reactivates it,\nwith no re-registration.\n","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\nof running a fresh registration: the number inherits the campaign's\napproval (no new brand or campaign, no extra carrier fee). Enable SMS\non the number first (`POST /v1/phone-numbers/{id}/sms`; its response\ntells you whether a reusable registration exists).\n","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\nsame way as the POST/PATCH/DELETE below (full read-write on one\nsub-resource). Encrypted secrets are never returned; only a boolean\nsaying whether a SIP password is stored. The account-scoped read\n(`GET /v1/whatsapp/calling?accountId=`) remains for callers that only\nknow the account id, and additionally carries account-level\nextras (billing eligibility, current-period spend).\n","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\nMeta calling.status=ENABLED with our Telnyx SIP endpoint, fetches and\nstores the Meta-issued SIP password (encrypted), and snapshots the\ncustomer's forward-to destination.\n","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"},"409":{"description":"This number is attached to a SIP trunk; detach it first (code invalid_resource_state)."},"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\nthe body are written; `undefined` leaves the stored value alone,\nexplicit `null` clears a nullable field. No Meta side effect, this\nonly changes local routing state consumed by the Telnyx webhook\nhandler.\n","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)\nand flips the local `callingEnabled` flag off. forwardTo and SIP\ncreds are preserved so a re-enable does not lose the destination.\n","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\ncaller ID on `tel:` call forwards until verified (carrier\nanti-spoofing); until then forwarded calls show a Zernio number\n(`callerIdMode: platform` on the calling config). This sends a\none-time code to the number by SMS or voice call. Re-POST to resend.\nZernio-purchased numbers never need this and get a 400.\n","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:`\ncall forwards present the business number itself as caller ID\n(`callerIdMode: business`).\n","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\nintegrations should use that path.\n\nRetrieve the current status of a purchased phone number. Poll this to\ntrack Meta pre-verification (US sync path) and, for regulated (Tier 3/4)\nnumbers, the async lifecycle: pending_regulatory → active (or\nregulatory_declined). When a regulated number has an Onfido ID step,\n`onfidoVerificationUrl` appears here once the order is placed. Forward\nit to the end user. (Or subscribe to the whatsapp.number.* webhooks\ninstead of polling.)\n","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"},"sipTrunkId":{"type":["string","null"],"description":"SIP trunk the number is attached to; null when not trunked. While attached, enabling Calls or WhatsApp calling, requesting WhatsApp verification, and releasing the number all return 409."}}}}}}}},"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\nintegrations should use that path.\n\nRelease a purchased phone number. This will:\n1. Disconnect any linked WhatsApp account\n2. Decrement the Stripe subscription quantity (or cancel if last number)\n3. Release the number from Telnyx\n4. Mark the number as released\n","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"},"409":{"description":"The number is attached to a SIP trunk; detach it first (code invalid_resource_state)."}}}},"/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\n(pending + active) plus the sandbox phone number. In practice there\nis at most one session per user since the sandbox is one-phone-per-user;\nthe array shape is preserved for forward compatibility.\n","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\nimmediately fires the verified sandbox template from the shared sandbox\nnumber to that phone. The session activates when the phone owner replies\nto that WhatsApp message: the reply itself is proof of ownership.\n\nOne phone per user: if the caller already has a non-expired session for\na DIFFERENT phone, the request is rejected with `invalid_field_value`\n(the message names the existing phone so it can be revoked first).\nRe-creating a session for the SAME phone is idempotent and refreshes\nthe verification template.\n\nIf Meta rejects the template send (not a WhatsApp number, paused WABA,\ntoken issue), the pending row is rolled back and the Meta error message\nis returned in `error` so the caller knows why.\n","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\nnumber itself, (c) the user already has a session for a different phone,\nor (d) Meta rejected the template send. The `error` field contains the\nspecific reason; `param` is set when a field is at fault.\n"},"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\nphone via the sandbox until they re-activate it. Existing conversations\nand messages already exchanged with that phone are untouched.\nRevocation only blocks FUTURE sends.\n\nSessions belonging to other users cannot be revoked; the response is\nthe same 400 as \"session not found\" so existence isn't leaked.\n","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"}}}},"/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.\nThese are actual WhatsApp group conversations on the platform.\n\nNot available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"WhatsApp 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.\n\nNot available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","subject"],"properties":{"accountId":{"type":"string","description":"WhatsApp 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,\nparticipants, and settings.\n\nNot available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.\n","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 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.\n\nNot available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.\n","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 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.\n\nNot available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.\n","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 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. A group holds at most 8 participants in total (a Meta limit), so later requests cannot grow a group past 8. Meta documents groups as invite-only, so people normally join through the group's invite link.\n\nNot available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.\n","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 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.\n\nNot available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.\n","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 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.\n\nNot available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.\n","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 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).\n\nNot available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.\n","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 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.\n\nNot available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.\n","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 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.\n\nNot available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.\n","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 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"}}}},"/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.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"WhatsApp 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.\nAfter creating, upload a Flow JSON definition, then publish to make it sendable.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","name","categories"],"properties":{"accountId":{"type":"string","description":"WhatsApp 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."},"endpointUri":{"type":"string","format":"uri","description":"HTTPS-only data exchange endpoint for the flow. Settable only while the flow is in DRAFT, and the flow's uploaded Flow JSON must declare data_api_version \"3.0\" for the endpoint to be used."}}},"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.\n","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 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, endpointUri) of a DRAFT flow. Published flows are immutable.\n","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 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"]}},"endpointUri":{"type":"string","format":"uri","description":"HTTPS-only data exchange endpoint for the flow. Settable only while the flow is in DRAFT, and the flow's uploaded Flow JSON must declare data_api_version \"3.0\" for the endpoint to be used."}}}}}},"responses":{"200":{"description":"Flow updated","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"400":{"description":"At least one of name, categories or endpointUri 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.\n","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 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.\n","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 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,\ncomponents (text inputs, dropdowns, date pickers, etc.), and navigation.\n\nMeta validates the JSON on upload and returns any validation errors.\nSee: https://developers.facebook.com/docs/whatsapp/flows/reference/flowjson\n","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 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\ninteractive iframe. The link is reused across calls (valid ~30 days); pass\ninvalidate=true to mint a fresh one (the previous link stops working).\n","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 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\nnative versioning), newest version first. Each entry is enriched with the version's\nlive name and status from Meta. A flow with no lineage returns only itself as version 1.\n","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 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\nbecome immutable and the flow can be sent to users. To update a published flow,\ncreate a new flow (optionally cloning this one via cloneFlowId).\n","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 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\nor opened, but existing active sessions may continue until they complete.\n","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 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/encryption-key":{"get":{"x-resource-group":"accounts","operationId":"getWhatsAppFlowsEncryptionKey","tags":["WhatsApp Flows"],"summary":"Get Flows encryption key status","description":"Read the RSA business public key registered on the phone number for WhatsApp Flows\nendpoint encryption. Only one key is active per phone number at a time. Flows that\nuse flow_action: data_exchange (an endpoint-backed flow) stop working at runtime\nuntil the endpoint serves the matching private key, and Meta rejects publish with\nerror code 139002 (\"Missing Flows Signed Public Key\") when no key is registered.\n`registered` reflects whether a key is present, never `signatureStatus` alone:\nMeta reports an unregistered key as MISMATCH rather than a null/absent value.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"WhatsApp account ID"}],"responses":{"200":{"description":"Encryption key status retrieved","content":{"application/json":{"schema":{"type":"object","properties":{"publicKey":{"type":["string","null"],"description":"The registered RSA public key in PEM format, or null when none is registered."},"signatureStatus":{"type":["string","null"],"description":"VALID (key matches Meta's records) or MISMATCH (no key registered, or the key does not match); null when unknown.","enum":["VALID","MISMATCH"]},"registered":{"type":"boolean","description":"Whether a key is currently registered. Derived from publicKey, not signatureStatus."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ResourceGroupForbidden"},"404":{"description":"WhatsApp account not found"},"502":{"description":"Meta rejected the request"}}},"post":{"x-resource-group":"accounts","operationId":"setWhatsAppFlowsEncryptionKey","tags":["WhatsApp Flows"],"summary":"Register a Flows encryption key","description":"Register (or replace) the RSA business public key for WhatsApp Flows endpoint\nencryption on the phone number. Uploading a new key replaces the previous one:\nonly one key is active per phone number. The corresponding private key must be\nserved by the flow's endpoint, or endpoint-backed flows (flow_action:\ndata_exchange) will fail at runtime even though the key is registered.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","businessPublicKey"],"properties":{"accountId":{"type":"string","description":"WhatsApp account ID"},"businessPublicKey":{"type":"string","description":"RSA public key in PEM format. Rejected if it is a private key or not a valid RSA public key PEM."}}}}}},"responses":{"200":{"description":"Encryption key registered","content":{"application/json":{"schema":{"type":"object","properties":{"success":{"type":"boolean"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/ResourceGroupForbidden"},"404":{"description":"WhatsApp account not found"},"502":{"description":"Meta rejected the request"}}}},"/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.\nWhen the recipient taps the button, the flow opens natively in WhatsApp.\nFlow responses are received via webhooks.\n","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 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\nnfm_reply messages received via webhook), newest first. Scope to a single flow\nwith `flowId`, which matches responses whose flow_token carries the `<flowId>:`\nprefix that Zernio stamps on auto-generated tokens at send time. Responses sent\nwith a custom integrator-supplied flow_token are not attributed to a flow.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"WhatsApp 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"}}}},"/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 text search on name, email and company.","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"},"description":"Case-insensitive substring match on the contact name, email and company. Phone numbers and other platform identifiers are not matched: they live on the contact channel, not on the contact. To reach a contact from an inbox webhook, use the conversation.contactId it already carries."},{"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","slack","sms"]}},{"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","enum":["instagram","facebook","telegram","twitter","bluesky","reddit","whatsapp","slack","sms"],"description":"Channel platform. Only the enum values support contact channels; any other platform is rejected with code platform_not_supported."},"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. Channel fields are all-or-nothing: accountId, platform and platformIdentifier must be sent together (code: missing_required_field). A platform outside the enum does not support contact channels (code: platform_not_supported, details.supportedPlatforms lists the valid values)."},"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. An accountId on a platform with no contact channels rejects the whole import (code: platform_not_supported, details.supportedPlatforms lists the valid values)."},"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"}}}},"/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"}}}},"/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","sms","slack"]},"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","null"]},"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."},"errorTraceId":{"type":["string","null"],"description":"Meta trace id (fbtrace_id) for the failed send. Quote this when escalating to Meta Direct Support. Only populated for status=failed on Meta platforms."},"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"}}},"summary":{"type":"object","description":"Delivery totals across all recipients in the broadcast, independent of pagination and status filtering.","properties":{"total":{"type":"integer"},"pending":{"type":"integer"},"sent":{"type":"integer"},"delivered":{"type":"integer"},"read":{"type":"integer"},"failed":{"type":"integer"}}}}}}}},"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. Zernio contact ids (24-character hex), as returned by the list-contacts endpoint. A platform identifier such as a WhatsApp wa_id is rejected with 400; use phones for raw numbers."},"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"}}}},"/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.\n","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, a WhatsApp-only node on another platform, or a WhatsApp interactive list node whose sections carry no rows)"},"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).\n","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 team and be on a workflow-supported platform (whatsapp, instagram, facebook, telegram, twitter, bluesky, reddit). Changing this triggers a graph revalidation against the new platform.\n"}}}}}},"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 (including a WhatsApp interactive list node whose sections carry no rows), 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.\n","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.\n","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.\n","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.\n","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.\n","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","slack"]},"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","null"]},"nextStepAt":{"type":["string","null"],"format":"date-time"},"stepsSent":{"type":"integer"},"lastStepSentAt":{"type":["string","null"],"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"}}}},"/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"},"503":{"$ref":"#/components/responses/TemporarilyUnavailable"}}},"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.\nWhen someone comments a matching keyword (or, with `trigger: story_reply`, replies\nto your Instagram story with one), they automatically receive a DM.\n\nTriggers (`trigger`):\n  * `comment` (default): fires on keyword comments on a post or reel.\n  * `story_reply`: fires when someone replies to your Instagram story with a keyword,\n    and answers them with a DM. Set `platformPostId` to a story media id to scope to\n    one story, or omit it to match replies to any story.\n\nTargeting (comment trigger):\n  * Per-post: set `platformPostId` to scope to one specific post (only one active\n    per-post automation is allowed per post).\n  * Account-wide (\"any post\"): omit `platformPostId` (and `postId`). The automation\n    evaluates every comment on every post on the account. You can stack unlimited\n    account-wide automations, each with its own keyword set, and they all run\n    independently. Per-post automations take priority on their post.\n\nAudience (`audience`, Instagram only): restrict the automation to followers or\nnon-followers, and/or to accounts above a follower count. Instagram only reveals the\nfollow relationship for people who have messaged the account, so `audience.whenUnknown`\ndecides what happens for everyone else - including `verify`, which sends a one-tap\nconfirmation DM (`followGate`) and then delivers the real DM automatically. People we\nalready know follow you skip the tap entirely.\n\nSet `alsoMatchInDms: true` on a `comment` automation to also answer people who send\na keyword as a direct message instead of commenting it. One automation then covers\nboth doors, and each door is deduplicated separately (someone who already got the DM\nfrom their comment still gets it if they later DM the keyword). Requires at least one\nkeyword.\n\nLinks in the DM's buttons can be click-tracked (`linkTracking`, on by default) and\nclickers optionally tagged (`clickTag`) for segmentation. Stats returned include\ndelivered, read, and link clicks.\n","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","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio post ID (24 hexadecimal characters); platform IDs return 400. Optional and never required. Use it INSTEAD of platformPostId to bind a per-post automation to a not-yet-published Zernio post: the automation stays pending and arms itself when that post publishes. For a post already live on the platform, pass platformPostId alone and omit this."},"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."},"503":{"$ref":"#/components/responses/TemporarilyUnavailable"}}}},"/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"},"platformError":{"type":"object","description":"Platform error codes of the failed DM (Meta `code` and `error_subcode`), when the platform sent them. Absent on successful rows and on rows written before this field existed.","properties":{"code":{"type":"integer"},"subcode":{"type":"integer"}}},"privateReplyConsumed":{"type":"boolean","description":"True when the failed send spent the comment's single Instagram private reply (subcode 1545133 or 2534023), the same rule as `details.privateReplyConsumed` on the private-reply endpoint. Absent on direct DMs, on Facebook, and on rows written before this field existed."},"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.\nPass `buttons: []` to clear all buttons. When `buttons` is non-empty, `dmMessage` (the new\none if you're changing it, otherwise the stored one) must be 640 characters or less.\n","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"},"platformError":{"type":"object","description":"Platform error codes of the failed DM (Meta `code` and `error_subcode`), when the platform sent them. Absent on successful rows and on rows written before this field existed.","properties":{"code":{"type":"integer"},"subcode":{"type":"integer"}}},"privateReplyConsumed":{"type":"boolean","description":"True when the failed send spent the comment's single Instagram private reply (subcode 1545133 or 2534023), the same rule as `details.privateReplyConsumed` on the private-reply endpoint. Absent on direct DMs, on Facebook, and on rows written before this field existed."},"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"}}}},"/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.\nUse source=all to include externally-synced ads from platform ad managers.\nIf no date range is provided, defaults to the last 90 days. Date range is capped at 730 days max.\n\nTo find the Zernio ad behind a comment you see in Meta Business Manager, filter by\nplatformAdId (the Meta ad ID), effectiveObjectStoryId (Facebook), or\neffectiveInstagramMediaId (Instagram). Those are the post/media the ad's engagement\nlives on, and are also returned on each ad's `creative` object. Then call\nGET /v1/ads/{adId}/comments with the returned ad id.\n","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":"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":"adSetId","in":"query","schema":{"type":"string"},"description":"Platform ad set ID (filter ads within an ad set, the /{adset_id}/ads read of an adset-centric dashboard)."},{"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":{"$ref":"#/components/schemas/AdsListResponse"}}}},"202":{"description":"Historical data is incomplete and backfill remains pending.","headers":{"Retry-After":{"$ref":"#/components/headers/BackfillRetryAfter"}},"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AdsListResponse"},{"type":"object","required":["backfillPending"],"properties":{"backfillPending":{"type":"boolean","description":"Always true on this response. Part of the requested range is still being backfilled; retry until the request returns 200."}}}]}}}},"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/search-terms":{"get":{"x-resource-group":"ads","operationId":"getAdsSearchTerms","tags":["Ad Insights"],"x-platforms":["google"],"summary":"Google Ads search terms report","description":"The actual search queries that triggered your ads, with matched-keyword\nstatus and spend metrics, the raw material for wasted-spend analysis and\nnegative-keyword lists. Reads Google's `search_term_view`, cached for\nthe quota window; defaults to the last 30 days. Rows are ordered by\ncost, descending. Draws on the shared Google Ads operations budget.\nThe response carries `cachedAt` and `stale`, set when a quota-exhausted\ncall falls back to the last-good copy instead of a live read.","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"Google ads SocialAccount id."},{"name":"customerId","in":"query","schema":{"type":"string"},"description":"Numeric Google Ads customer id (no dashes). Defaults to the account's connected customer."},{"name":"fromDate","in":"query","schema":{"type":"string","format":"date"},"description":"Defaults to 30 days ago."},{"name":"toDate","in":"query","schema":{"type":"string","format":"date"},"description":"Defaults to today."},{"name":"campaignId","in":"query","schema":{"type":"string"},"description":"Numeric Google campaign id filter."},{"name":"adGroupId","in":"query","schema":{"type":"string"},"description":"Numeric Google ad group id filter."},{"name":"pageToken","in":"query","schema":{"type":"string"},"description":"Cursor from paging.nextPageToken of the previous page."}],"responses":{"200":{"description":"Search terms","content":{"application/json":{"schema":{"type":"object","properties":{"customerId":{"type":"string"},"data":{"type":"array","items":{"type":"object","properties":{"searchTerm":{"type":["string","null"]},"status":{"type":["string","null"],"description":"ADDED / EXCLUDED / ADDED_EXCLUDED / NONE: whether the term is already a keyword or a negative."},"matchType":{"type":["string","null"],"description":"How the term matched (BROAD, PHRASE, EXACT, NEAR_PHRASE, NEAR_EXACT)."},"campaignId":{"type":["string","null"]},"campaignName":{"type":["string","null"]},"adGroupId":{"type":["string","null"]},"adGroupName":{"type":["string","null"]},"impressions":{"type":"integer"},"clicks":{"type":"integer"},"costMicros":{"type":"integer","description":"Cost in micros of the account currency (divide by 1,000,000)."},"conversions":{"type":"number"},"conversionsValue":{"type":"number"}}}},"paging":{"type":"object","properties":{"nextPageToken":{"type":["string","null"],"description":"Null when the last page was returned."}}},"cachedAt":{"type":["string","null"],"format":"date-time","description":"When this data was fetched from Google. Null when it was never served from cache."},"stale":{"type":"boolean","description":"True when Google's daily API quota was exhausted and this is the last successful fetch, not a live read."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget exhausted; retry later."},"501":{"description":"Only available on Google Ads accounts"}}}},"/v1/ads/bid-strategies":{"get":{"x-resource-group":"ads","operationId":"listBidStrategies","tags":["Ad Campaigns"],"x-platforms":["google"],"summary":"List portfolio bid strategies","description":"Bidding strategy report: type, status, campaign count, clicks, cost, cost per conversion, impressions, average CPC and conversions over the date range (default last 30 days). Reads Google's `bidding_strategy` resource, cached for the quota window. Draws on the shared Google Ads operations budget. The response carries `cachedAt` and `stale`, set when a quota-exhausted call falls back to the last-good copy instead of a live read.","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"Google ads SocialAccount id."},{"name":"customerId","in":"query","schema":{"type":"string"},"description":"Numeric Google Ads customer id (no dashes). Defaults to the account's connected customer."},{"name":"fromDate","in":"query","schema":{"type":"string","format":"date"},"description":"Defaults to 30 days ago."},{"name":"toDate","in":"query","schema":{"type":"string","format":"date"},"description":"Defaults to today."}],"responses":{"200":{"description":"Portfolio bid strategies","content":{"application/json":{"schema":{"type":"object","properties":{"customerId":{"type":"string"},"currency":{"type":"string","description":"Account currency code; money fields are in this currency's units."},"strategies":{"type":"array","items":{"$ref":"#/components/schemas/PortfolioBidStrategy"}},"cachedAt":{"type":["string","null"],"format":"date-time","description":"When this data was fetched from Google. Null when it was never served from cache."},"stale":{"type":"boolean","description":"True when Google's daily API quota was exhausted and this is the last successful fetch, not a live read."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget exhausted; retry later."},"501":{"description":"Only available on Google Ads accounts"}}},"post":{"x-resource-group":"ads","operationId":"createBidStrategy","tags":["Ad Campaigns"],"x-platforms":["google"],"summary":"Create portfolio bid strategy","description":"Creates a standalone bid strategy shared across campaigns. Attach it to a campaign with `portfolioBidStrategyId` on POST /v1/ads/create, PUT /v1/ads/campaigns/{campaignId}, or PUT /v1/ads/ad-sets/{adSetId}. Attaching a strategy aligned to a shared budget fails there with a 400 (Google's `BIDDING_STRATEGY_AND_BUDGET_MUST_BE_ALIGNED`); this is not retryable.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","name","type"],"properties":{"accountId":{"type":"string","description":"Google ads SocialAccount id."},"customerId":{"type":"string","description":"Numeric Google Ads customer id (no dashes). Defaults to the account's connected customer."},"name":{"type":"string","minLength":1,"maxLength":255},"type":{"type":"string","enum":["TARGET_CPA","TARGET_ROAS","MAXIMIZE_CONVERSIONS","MAXIMIZE_CONVERSION_VALUE"]},"targetCpa":{"type":"number","exclusiveMinimum":0,"description":"Required when type is TARGET_CPA, in the account's currency units."},"targetRoas":{"type":"number","exclusiveMinimum":0,"description":"Required when type is TARGET_ROAS; a multiplier (2.0 = 2.0x)."}}}}}},"responses":{"201":{"description":"Bid strategy created","content":{"application/json":{"schema":{"type":"object","properties":{"strategy":{"type":"object","properties":{"customerId":{"type":"string"},"id":{"type":"string","description":"Numeric bid strategy id; pass as portfolioBidStrategyId or in the {strategyId} path."},"resourceName":{"type":"string"}}}}}}}},"400":{"description":"Invalid input, or Google rejected the strategy (e.g. shared-budget alignment). The message carries Google's error."},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"422":{"description":"No Google Ads customer accounts on this connection. Reconnect Google Ads."},"429":{"description":"Google Ads operations budget exhausted; retry later."},"501":{"description":"Only available on Google Ads accounts"}}}},"/v1/ads/bid-strategies/{strategyId}":{"patch":{"x-resource-group":"ads","operationId":"updateBidStrategy","tags":["Ad Campaigns"],"x-platforms":["google"],"summary":"Update portfolio bid strategy","description":"Renames or retargets a portfolio bid strategy. The strategy's status is output only on Google's side, so it cannot be changed here; remove a strategy in Google Ads. `type` is only needed alongside `targetCpa`/`targetRoas` to disambiguate the field Google writes to (TARGET_CPA and MAXIMIZE_CONVERSIONS both take a target CPA; TARGET_ROAS and MAXIMIZE_CONVERSION_VALUE both take a target ROAS); the strategy's family is otherwise immutable once created.","security":[{"bearerAuth":[]}],"parameters":[{"name":"strategyId","in":"path","required":true,"schema":{"type":"string"},"description":"Numeric Google Ads bid strategy id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId"],"properties":{"accountId":{"type":"string","description":"Google ads SocialAccount id."},"customerId":{"type":"string","description":"Numeric Google Ads customer id (no dashes). Defaults to the account's connected customer."},"name":{"type":"string","minLength":1,"maxLength":255},"type":{"type":"string","enum":["TARGET_CPA","TARGET_ROAS","MAXIMIZE_CONVERSIONS","MAXIMIZE_CONVERSION_VALUE"]},"targetCpa":{"type":"number","exclusiveMinimum":0},"targetRoas":{"type":"number","exclusiveMinimum":0}}}}}},"responses":{"200":{"description":"Bid strategy updated","content":{"application/json":{"schema":{"type":"object","properties":{"strategy":{"type":"object","properties":{"customerId":{"type":"string"}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget exhausted; retry later."},"501":{"description":"Only available on Google Ads accounts"}}}},"/v1/ads/local-services/leads":{"get":{"x-resource-group":"ads","operationId":"listLocalServicesLeads","tags":["Ad Insights"],"x-platforms":["google"],"summary":"Google Local Services Ads leads","description":"Leads generated by Local Services Ads (phone calls, messages, bookings),\nread live from Google's `local_services_lead` resource, newest first.\nNo persistence: Google is the source of truth and lead/credit statuses\nkeep changing server-side. Google never returns healthcare-category\nleads, and `WIPED_OUT` leads arrive with contact erased (`contact` is\nnull). Draws on the shared Google Ads operations budget.","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"Google ads SocialAccount id."},{"name":"customerId","in":"query","schema":{"type":"string"},"description":"Numeric Google Ads customer id (no dashes). Defaults to the account's connected customer."},{"name":"fromDate","in":"query","schema":{"type":"string","format":"date"},"description":"Leads created at/after this day."},{"name":"toDate","in":"query","schema":{"type":"string","format":"date"},"description":"Leads created at/before this day."},{"name":"leadType","in":"query","schema":{"type":"string","enum":["PHONE_CALL","MESSAGE","BOOKING"]}},{"name":"leadStatus","in":"query","schema":{"type":"string"},"description":"Google LocalServicesLeadStatus enum value (e.g. NEW, BOOKED, WIPED_OUT)."},{"name":"chargedOnly","in":"query","schema":{"type":"boolean"},"description":"true = only leads Google charged for."},{"name":"pageToken","in":"query","schema":{"type":"string"},"description":"Cursor from paging.nextPageToken of the previous page."}],"responses":{"200":{"description":"Local Services leads","content":{"application/json":{"schema":{"type":"object","properties":{"customerId":{"type":"string"},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":["string","null"],"description":"Lead id; pass to /v1/ads/local-services/leads/{leadId}/conversations."},"leadType":{"type":["string","null"],"description":"PHONE_CALL / MESSAGE / BOOKING."},"categoryId":{"type":["string","null"]},"serviceId":{"type":["string","null"]},"contact":{"type":["object","null"],"description":"Null for WIPED_OUT leads (contact erased by Google).","properties":{"consumerName":{"type":["string","null"]},"email":{"type":["string","null"]},"phoneNumber":{"type":["string","null"]}}},"status":{"type":["string","null"]},"createdTime":{"type":["string","null"],"description":"Google datetime in the customer's timezone (YYYY-MM-DD HH:MM:SS)."},"locale":{"type":["string","null"]},"charged":{"type":"boolean"},"creditState":{"type":["string","null"]},"creditStateLastUpdate":{"type":["string","null"]}}}},"paging":{"type":"object","properties":{"nextPageToken":{"type":["string","null"],"description":"Null when the last page was returned."}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget exhausted; retry later."},"501":{"description":"Only available on Google Ads accounts"}}}},"/v1/ads/local-services/leads/{leadId}/conversations":{"get":{"x-resource-group":"ads","operationId":"listLocalServicesLeadConversations","tags":["Ad Insights"],"x-platforms":["google"],"summary":"List lead conversations","description":"Conversation entries of one Local Services lead: phone calls (duration,\nrecording URL) and messages (text, attachment URLs), oldest first. Read\nlive from `local_services_lead_conversation`, always scoped to a single\nlead. Call-recording URLs require read access on the Google Ads account.\nDraws on the shared Google Ads operations budget.","security":[{"bearerAuth":[]}],"parameters":[{"name":"leadId","in":"path","required":true,"schema":{"type":"string"},"description":"Numeric lead id from /v1/ads/local-services/leads."},{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"Google ads SocialAccount id."},{"name":"customerId","in":"query","schema":{"type":"string"},"description":"Numeric Google Ads customer id (no dashes). Defaults to the account's connected customer."},{"name":"pageToken","in":"query","schema":{"type":"string"},"description":"Cursor from paging.nextPageToken of the previous page."}],"responses":{"200":{"description":"Lead conversations","content":{"application/json":{"schema":{"type":"object","properties":{"customerId":{"type":"string"},"data":{"type":"array","items":{"type":"object","properties":{"id":{"type":["string","null"]},"channel":{"type":["string","null"],"description":"PHONE_CALL / MESSAGE / SMS / EMAIL / WHATSAPP / ADS_API."},"participantType":{"type":["string","null"],"description":"ADVERTISER or CONSUMER."},"eventDateTime":{"type":["string","null"]},"phoneCall":{"type":["object","null"],"description":"Only on PHONE_CALL entries.","properties":{"callDurationMillis":{"type":["integer","null"]},"callRecordingUrl":{"type":["string","null"]}}},"message":{"type":["object","null"],"description":"Only on message-channel entries.","properties":{"text":{"type":["string","null"]},"attachmentUrls":{"type":"array","items":{"type":"string"}}}}}}},"paging":{"type":"object","properties":{"nextPageToken":{"type":["string","null"],"description":"Null when the last page was returned."}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget exhausted; retry later."},"501":{"description":"Only available on Google Ads accounts"}}}},"/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\nconnected Google Ads accounts, one row per ad-group keyword. Refreshed about\nonce a week per Google Ads customer (the keyword sweep rides the ads discovery\npass on a slower slot, to stay inside Google's shared daily API quota), so\nkeywords added on Google can take several days to appear. A customer synced\nfor the first time is populated on the next discovery pass rather than\nwaiting for its weekly slot, and connecting an account or triggering a\nmanual sync refreshes it immediately.\nCampaign-level negative keywords are not included; only ad-group-level\ncriteria are.\n","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":"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":{"$ref":"#/components/schemas/AdKeyword"}},"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":"addAdKeywords","tags":["Ad Campaigns"],"x-platforms":["google"],"summary":"Add Search ad-group keywords","description":"Adds one or more keyword criteria to an existing Google Search ad group,\nwithout touching the keywords already there (unlike the whole-set diff on\n`PUT /v1/ads/{adId}`, `keywords`/`negativeKeywords` in `platformSpecificData`,\nwhich replaces the set). Set `negative: true` to add ad-group-level negatives\ninstead of positive keywords.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","adSetId","keywords"],"properties":{"accountId":{"type":"string","description":"Account ID (Google Ads)"},"adSetId":{"type":"string","description":"Google ad group ID to add the keywords to"},"keywords":{"type":"array","minItems":1,"maxItems":1000,"items":{"anyOf":[{"type":"string","description":"Keyword text; defaults to BROAD match"},{"type":"object","required":["text"],"properties":{"text":{"type":"string","minLength":1,"maxLength":80},"matchType":{"type":"string","enum":["exact","phrase","broad"]}}}]}},"negative":{"type":"boolean","default":false,"description":"Add as ad-group-level negatives instead of positive keywords"}}}}}},"responses":{"201":{"description":"Keywords added","content":{"application/json":{"schema":{"type":"object","properties":{"keywords":{"type":"array","items":{"$ref":"#/components/schemas/AdKeyword"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Only available on Google Ads accounts"}}}},"/v1/ads/keywords/{keywordId}":{"patch":{"x-resource-group":"ads","operationId":"updateAdKeyword","tags":["Ad Campaigns"],"x-platforms":["google"],"summary":"Pause or enable a Search keyword","description":"Changes `ad_group_criterion.status` for one keyword criterion (M.140).\nNegative keywords have no status on Google and cannot be paused or enabled.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"keywordId","in":"path","required":true,"schema":{"type":"string"},"description":"Zernio keyword ID (not the Google criterion ID)"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["status"],"properties":{"status":{"type":"string","enum":["active","paused"]}}}}}},"responses":{"200":{"description":"Keyword updated","content":{"application/json":{"schema":{"type":"object","properties":{"keyword":{"$ref":"#/components/schemas/AdKeyword"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"422":{"description":"Negative keywords have no status on Google; they cannot be paused or enabled."}}},"delete":{"x-resource-group":"ads","operationId":"removeAdKeyword","tags":["Ad Campaigns"],"x-platforms":["google"],"summary":"Remove a Search keyword","description":"Removes one keyword criterion (positive or negative) from its ad group (M.140).","security":[{"bearerAuth":[]}],"parameters":[{"name":"keywordId","in":"path","required":true,"schema":{"type":"string"},"description":"Zernio keyword ID (not the Google criterion ID)"}],"responses":{"200":{"description":"Keyword removed","content":{"application/json":{"schema":{"type":"object","properties":{"removed":{"type":"boolean","description":"Always true on success"},"keywordId":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}}},"/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.\nMetrics (spend, impressions, clicks, etc.) are summed across all ads in each campaign.\nCampaign status is derived from child ad statuses (active > pending_review > paused > error > completed > cancelled > rejected).\nGoogle campaign budgets include amountMicros, explicitlyShared, resourceName and\ndeliveryMethod after the next successful sync. This endpoint does not fetch Google live.\n","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":"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."},{"name":"hasDelivery","in":"query","schema":{"type":"boolean"},"description":"Return only campaigns that delivered between `fromDate` and `toDate`: spend above zero, or impressions served at zero spend. Unlike `status`, which reads a campaign's CURRENT state, this filters on what happened inside the window. Filters the campaign set itself, so `pagination.total` counts only matching campaigns. Mirrors the same filter on /v1/ads/tree."},{"name":"minSpend","in":"query","schema":{"type":"number","minimum":0},"description":"Return only campaigns whose spend between `fromDate` and `toDate` reaches this amount, in each campaign's OWN currency (the `currency` field on the campaign). Implies `hasDelivery`; `minSpend=0` applies no filter. Mirrors the same filter on /v1/ads/tree."}],"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"}}},"example":{"campaigns":[{"platformCampaignId":"123456789","platform":"google","campaignName":"Search campaign","budget":{"amount":5,"amountMicros":"5000000","type":"daily","explicitlyShared":false,"resourceName":"customers/9122445560/campaignBudgets/1234567890","deliveryMethod":"STANDARD"}}],"pagination":{"page":1,"limit":20,"total":1,"pages":1}}}}},"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."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}},"post":{"x-resource-group":"ads","operationId":"createAdCampaign","tags":["Ad Campaigns"],"x-platforms":["meta","google","linkedin","tiktok","x","pinterest","openai"],"summary":"Create a standalone campaign","description":"Creates a campaign WITHOUT its first ad set / ad, on the platform of the given\n`accountId`. Ad sets join it later via `existingCampaignId` on the create endpoints.\nPlatform notes: on Meta a budget here is campaign-level (CBO) by definition; omit it\nfor ABO (each ad set carries its own budget), and `specialAdCategories` is Meta-only\n(400 elsewhere); `bidStrategy` is Meta and Google (400 elsewhere), and Google also\naccepts `portfolioBidStrategyId` instead. Google, X and OpenAI require a budget\n(422 without one; OpenAI accepts only `budgetType: lifetime`, Google only\n`budgetType: daily`). LinkedIn creates the\ncampaign GROUP (our campaign level) and rejects a budget, which lives on the\ncampaign (ad set) level there; it comes back `status: DRAFT`. TikTok campaigns are\ncreated without a status and report `ENABLE`. Created `PAUSED` unless\n`status: ACTIVE` where the platform supports it.\n\n**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); its platform decides where the campaign is created."},"adAccountId":{"type":"string","description":"Platform ad account id (Meta act_<n>, Google customer id, LinkedIn account id, ...)."},"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","page_likes"],"description":"Mapped to the ODAX objective (same mapping as POST /v1/ads/create)."},"isSkadnetworkAttribution":{"type":"boolean","description":"Meta app promotion only. Immutable campaign flag. Set true for iOS 14+ SKAdNetwork campaigns and supply promotedObject.applicationId plus promotedObject.objectStoreUrl. The campaign receives promotedObject only when this flag is true. Cannot be changed on an existing campaign."},"promotedObject":{"$ref":"#/components/schemas/AdPromotedObject"},"buyingType":{"type":"string","enum":["AUCTION","RESERVED"],"description":"Meta only. OMIT on non-Meta platforms (Google Ads, etc.): any value is rejected with a 400. On Meta the server applies AUCTION when omitted, so it does not need to be sent; RESERVED = Reach & Frequency. SKAdNetwork app promotion requires AUCTION."},"validateOnly":{"type":"boolean","description":"Meta only. Runs campaign validation without creating or persisting a campaign; Idempotency-Key storage is bypassed. Returns HTTP 200 with validateOnly true and status VALIDATED."},"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 on Meta, set the strategy there instead. On Google: LOWEST_COST_WITHOUT_CAP = Maximize Conversions, COST_CAP + bidAmount = Target CPA, LOWEST_COST_WITH_MIN_ROAS + roasAverageFloor = Target ROAS, LOWEST_COST_WITH_BID_CAP + bidAmount = Maximize Clicks with a CPC ceiling; portfolioBidStrategyId attaches a portfolio strategy instead."},"bidAmount":{"type":"number","description":"Whole currency units (USD: 5 = $5.00). Required for LOWEST_COST_WITH_BID_CAP and COST_CAP; ignored otherwise. On Meta, validated here but NOT stored: the campaign object has no bid_amount field, only bid_strategy lives on it, and the amount takes effect once an ad set joins this campaign (existingCampaignId on POST /v1/ads/create) and supplies its own bidAmount there. On Google, stored directly on the campaign's bidding strategy."},"roasAverageFloor":{"type":"number","description":"Decimal ROAS multiplier (2.0 = 2.0x). Required for LOWEST_COST_WITH_MIN_ROAS."},"portfolioBidStrategyId":{"type":"string","pattern":"^\\d+$","description":"Google only. Attach an existing portfolio bid strategy (numeric id from GET /v1/ads/bid-strategies) to the new campaign instead of a standard one. Exclusive with bidStrategy."}}},"example":{"accountId":"69fc524892b3d8e85f893e73","adAccountId":"act_757082720485182","name":"iOS app campaign","goal":"app_promotion","isSkadnetworkAttribution":true,"promotedObject":{"applicationId":"123456789","objectStoreUrl":"https://apps.apple.com/us/app/id123456789"},"buyingType":"AUCTION","status":"PAUSED","validateOnly":true}}}},"responses":{"200":{"description":"Campaign validation passed without creating a campaign.","content":{"application/json":{"schema":{"type":"object","properties":{"validateOnly":{"type":"boolean","description":"Always true."},"adAccountId":{"type":"string"},"campaignId":{"type":"string","const":"","description":"Empty because no campaign was created."},"objective":{"type":"string"},"status":{"type":"string","const":"VALIDATED"}}},"example":{"validateOnly":true,"adAccountId":"act_757082720485182","campaignId":"","objective":"OUTCOME_APP_PROMOTION","status":"VALIDATED"}}}},"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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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":"Writes the campaign's own on/off switch, then lets the platform cascade delivery to its ad sets and ads.\nMakes one platform API call, not one per ad.\n\nThe switch is always written, whatever delivery status the ads underneath report: an ad still in review\ndoes not block resuming its campaign. The echoed `status` is the confirmation that it landed.\n\n`updated` / `skipped` describe only the ads whose own stored status CHANGED alongside it, so\n`updated: 0` is a normal successful response, not a no-op. Ads are skipped when they are in a terminal\nstatus (rejected, completed, cancelled), already in the target state, or switched on but not yet\ndelivering. The last group keeps its `pending_review` / `error` status until the platform reports what\nit became. `skippedReasons` names which case applies.\n\nOn Meta this flips the campaign only. An ad set paused in its own right stays paused, so pair this with\nPUT /v1/ads/ad-sets/{adSetId}/status when you also need the ad set switched back on.\n\nGoogle keeps an independent on/off switch at campaign, ad group and ad level and the most restrictive\none wins, so `active` switches the campaign on TOGETHER with the ad groups and ads Zernio tracks under\nit, in one mutate. Without that the campaign reads ENABLED while a paused ad group or ad keeps it from\nserving. `paused` writes the campaign alone, which already stops delivery and leaves each ad's own\nswitch as you set it.\n","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":{"status":{"type":"string","enum":["active","paused"],"description":"The status written to the campaign"},"updated":{"type":"integer","description":"Number of ads whose own stored status changed too. 0 is normal on a resume whose ads are all awaiting the platform."},"skipped":{"type":"integer","description":"Number of ads whose own status was left as it was"},"skippedReasons":{"type":"array","items":{"type":"string"},"description":"Why each group of ads was skipped"}}}}}},"400":{"description":"Invalid input or campaign spans multiple accounts"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."},"404":{"description":"No ads found for this campaign"}}}},"/v1/ads/campaigns/{campaignId}/ad-schedule":{"get":{"x-resource-group":"ads","operationId":"getCampaignAdSchedule","tags":["Ad Campaigns"],"x-platforms":["google"],"summary":"Read a campaign's ad schedule (dayparting)","description":"The windows a Google campaign serves in, with the bid modifier on each, plus the\ncriterion ids Google minted for them.\n\nAn EMPTY `schedule` is meaningful and is not a failed lookup: Google has no\n\"all day\" criterion, so a campaign with no ad schedule serves around the clock.\n`servesAroundTheClock` states that explicitly.\n\nSet `includePerformance=true` to also get delivery split by day of week and by hour,\nwhich is the evidence for deciding what the schedule should be. It is one extra\nGoogle call segmented by both dimensions at once, so the two views always agree.\n\nGoogle Ads only. The response carries `cachedAt` and `stale`, set when a\nquota-exhausted call falls back to the last-good copy instead of a live read.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"campaignId","in":"path","required":true,"schema":{"type":"string"},"description":"Numeric Google platform campaign id."},{"name":"platform","in":"query","required":false,"schema":{"type":"string","enum":["google"]},"description":"Disambiguates the campaign id when the connection spans platforms."},{"name":"includePerformance","in":"query","required":false,"schema":{"type":"boolean"},"description":"Also return delivery by day of week and by hour. Costs one extra Google call."},{"name":"windowDays","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":90,"default":30},"description":"Trailing window for the performance split. Ignored when fromDate and toDate are both given."},{"name":"fromDate","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"Start of an explicit performance range (YYYY-MM-DD). Use together with toDate."},{"name":"toDate","in":"query","required":false,"schema":{"type":"string","format":"date"},"description":"End of an explicit performance range (YYYY-MM-DD). Must be on or after fromDate."}],"responses":{"200":{"description":"The campaign's ad schedule","content":{"application/json":{"schema":{"type":"object","properties":{"campaignId":{"type":"string"},"schedule":{"type":"array","items":{"$ref":"#/components/schemas/AdScheduleWindow"}},"servesAroundTheClock":{"type":"boolean","description":"True when the campaign carries no ad schedule at all, so it can serve at any time."},"cachedAt":{"type":["string","null"],"format":"date-time"},"stale":{"type":"boolean","description":"True when a quota-exhausted read served the last-good copy."},"performance":{"type":"object","description":"Only present when includePerformance=true.","properties":{"windowDays":{"type":["integer","null"],"description":"The trailing window used, or null when an explicit fromDate/toDate range was given."},"byDayOfWeek":{"type":"array","description":"One entry per day that delivered, Monday first.","items":{"type":"object","properties":{"dayOfWeek":{"type":"string","enum":["MONDAY","TUESDAY","WEDNESDAY","THURSDAY","FRIDAY","SATURDAY","SUNDAY"]},"impressions":{"type":"integer"},"clicks":{"type":"integer"},"cost":{"type":"number","description":"Account currency, not USD-normalized."},"conversions":{"type":"number"}}}},"byHour":{"type":"array","description":"One entry per hour that delivered, 0-23 in the account time zone.","items":{"type":"object","properties":{"hour":{"type":"integer","minimum":0,"maximum":23},"impressions":{"type":"integer"},"clicks":{"type":"integer"},"cost":{"type":"number","description":"Account currency, not USD-normalized."},"conversions":{"type":"number"}}}}}}}}}}},"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."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"501":{"description":"Not a Google Ads campaign: ad schedules are a Google criterion."}}},"put":{"x-resource-group":"ads","operationId":"updateCampaignAdSchedule","tags":["Ad Campaigns"],"x-platforms":["google"],"summary":"Replace a campaign's ad schedule (dayparting)","description":"Replaces the campaign's whole ad schedule with the windows you send. This is a\nREPLACE, not a merge: windows you leave out stop serving.\n\nSend `schedule: []` to clear dayparting, which returns the campaign to serving around\nthe clock.\n\nGoogle rules enforced here, so you get a named field instead of a criterion error:\nat most 6 windows per day, a window must end after it starts, windows on the same day\nmay not overlap, `endHour` 24 is midnight and cannot carry minutes, and minutes are\nquarter-hours only (0, 15, 30, 45). `bidModifier` is 0.1-10.0; Google's 0 means\n\"off\" for devices only, so a window is switched off by leaving it out.\n\nWindows are half-open (Google is exclusive of the end minute), so 09:00-12:00 and\n12:00-17:00 on the same day are adjacent and both valid.\n\nGoogle cannot edit an ad schedule in place (every AdScheduleInfo field is prohibited on\nupdate), so this removes the live criteria and creates the new ones in a single atomic\nmutate. The response is read back from Google and carries the new criterion ids.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"campaignId","in":"path","required":true,"schema":{"type":"string"},"description":"Numeric Google platform campaign id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["schedule"],"properties":{"schedule":{"type":"array","maxItems":42,"description":"The complete set of windows. Required, so clearing the schedule is always deliberate rather than an omission.","items":{"type":"object","required":["dayOfWeek","startHour","endHour"],"properties":{"dayOfWeek":{"type":"string","enum":["MONDAY","TUESDAY","WEDNESDAY","THURSDAY","FRIDAY","SATURDAY","SUNDAY"]},"startHour":{"type":"integer","minimum":0,"maximum":23},"startMinute":{"type":"integer","enum":[0,15,30,45],"default":0,"description":"Quarter-hours only."},"endHour":{"type":"integer","minimum":0,"maximum":24,"description":"24 means midnight at the end of the day."},"endMinute":{"type":"integer","enum":[0,15,30,45],"default":0,"description":"Quarter-hours only. Must be 0 when endHour is 24."},"bidModifier":{"type":["number","null"],"minimum":0.1,"maximum":10,"default":null,"description":"Bid adjustment for this window. Null runs it at the campaign bid."}}}}}},"examples":{"officeHours":{"summary":"Weekday office hours, bidding up over lunch","value":{"schedule":[{"dayOfWeek":"MONDAY","startHour":9,"endHour":17},{"dayOfWeek":"TUESDAY","startHour":9,"endHour":17},{"dayOfWeek":"WEDNESDAY","startHour":9,"endHour":12,"bidModifier":1.5},{"dayOfWeek":"WEDNESDAY","startHour":12,"endHour":17}]}},"clearSchedule":{"summary":"Serve around the clock again","value":{"schedule":[]}}}}}},"responses":{"200":{"description":"The schedule as Google stored it","content":{"application/json":{"schema":{"type":"object","properties":{"campaignId":{"type":"string"},"schedule":{"type":"array","items":{"$ref":"#/components/schemas/AdScheduleWindow"}},"servesAroundTheClock":{"type":"boolean"}}}}}},"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."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"422":{"description":"The schedule breaks a Google rule: too many windows on a day, an overlap, a window that ends before it starts, minutes on hour 24, or a bid modifier outside 0.1-10.0."},"501":{"description":"Not a Google Ads campaign: ad schedules are a Google criterion."}}}},"/v1/ads/campaigns/{campaignId}/bidding":{"get":{"x-resource-group":"ads","operationId":"getCampaignBidding","tags":["Ad Campaigns"],"x-platforms":["google"],"summary":"Read a campaign's current bidding","description":"Read of the campaign's bidding strategy on Google, cached for the quota window, for\npre-filling the bid strategy block before a PUT to /v1/ads/campaigns/{campaignId}.\nGoogle Ads only; `platform` is required and rejected when it is anything else, since\na `campaignId` is not globally unique. The response carries `cachedAt` and `stale`,\nset when a quota-exhausted call falls back to the last-good copy instead of a live\nread.\n\nMaps Google's bidding strategy onto the same triplet PUT accepts: `LOWEST_COST_WITHOUT_CAP`\n(Maximize Conversions, no target), `COST_CAP` + `bidAmount` (Target CPA), `LOWEST_COST_WITH_MIN_ROAS`\n+ `roasAverageFloor` (Target ROAS), `LOWEST_COST_WITH_BID_CAP` + `bidAmount` (Maximize Clicks with\na CPC ceiling). A campaign on a portfolio strategy returns `portfolio` (id + name) and\n`bidSpec.portfolioBidStrategyId` instead of the triplet. Anything else (Manual CPC, Target\nImpression Share, ...) returns `bidSpec: null`; show `biddingStrategyType` as-is.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"campaignId","in":"path","required":true,"schema":{"type":"string"},"description":"Numeric Google platform campaign id."},{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"Zernio Google Ads SocialAccount id: resolves the customer id + refresh token."},{"name":"platform","in":"query","required":true,"schema":{"type":"string","enum":["google"]},"description":"Required: campaign IDs are not globally unique. Only \"google\" is supported today."},{"name":"customerId","in":"query","required":false,"schema":{"type":"string"},"description":"Numeric Google Ads customer id (no dashes). Required when the connection has multiple Google Ads accounts; optional (and inferred) when it has only one."}],"responses":{"200":{"description":"Campaign bidding","content":{"application/json":{"schema":{"allOf":[{"type":"object","properties":{"campaignId":{"type":"string"}}},{"$ref":"#/components/schemas/CampaignBidding"}]}}}},"400":{"description":"Invalid input (accountId, customerId, or a non-numeric campaignId), or a platform other than \"google\""},"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/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Not a Google Ads account: the connection behind accountId resolves to another platform."}}}},"/v1/ads/campaigns/{campaignId}":{"get":{"x-resource-group":"ads","operationId":"getAdCampaignDetails","tags":["Ad Campaigns"],"x-platforms":["meta"],"summary":"Get live campaign details","description":"Reads one campaign live from Meta, returned verbatim, so a caller that knows a\ncampaign id no longer has to page `GET /v1/ads/campaigns` to find it. The default\nprojection covers name, status, objective, buying type, bid strategy, budgets,\nspend cap, schedule and `issues_info`. `fields` is a raw-passthrough override;\nunknown fields return Meta's 400 verbatim. A campaign the resolved connection\ncannot see comes back as Meta's own 400, not a 404.","security":[{"bearerAuth":[]}],"parameters":[{"name":"campaignId","in":"path","required":true,"schema":{"type":"string"},"description":"Meta campaign id (platformCampaignId)."},{"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","example":"id,name,status,daily_budget"},"description":"Comma-separated Graph field override. Supports nested {} projections and Graph field modifiers."}],"responses":{"200":{"description":"The campaign as returned by Meta","content":{"application/json":{"schema":{"type":"object","properties":{"campaign":{"type":"object","description":"Raw Meta campaign; keys are the requested Graph fields."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Only supported on Meta (facebook/instagram)"}}},"put":{"x-resource-group":"ads","operationId":"updateAdCampaign","tags":["Ad Campaigns"],"x-platforms":["meta","google","tiktok","linkedin","pinterest","x","openai"],"summary":"Update a campaign","description":"Campaign-level edits. Send at least one of `budget`, `bidStrategy`,\n`portfolioBidStrategyId`, `name` or `platformSpecificData`. An unsupported\nfield is always an error, never a silent drop.\n\n| Body field | Meta | Google | Others |\n|---|---|---|---|\n| `bidStrategy` | Yes | Yes | 501 |\n| `bidAmount`, `roasAverageFloor` | 400 (ad-set level) | Yes | 400 |\n| `portfolioBidStrategyId` | 400 | Yes | 400 |\n| `budget` (CBO; ABO returns 409) | Yes | Daily only | 501 |\n| `name` | Yes | 501 | 501 |\n| `platformSpecificData.spendCap` | Yes | 400 | 400 |\n| `accountId` (empty campaigns) | Yes | - | - |\n\nMeta budget edits check the live campaign budget, so an older local ABO stamp\ncannot block a CBO campaign. A successful edit repairs local ad budget fields.\nA live ABO campaign still returns 409 with the ad-set budget endpoint.\n\nOn Google: `LOWEST_COST_WITHOUT_CAP` = Maximize Conversions, `COST_CAP` +\n`bidAmount` = Target CPA, `LOWEST_COST_WITH_MIN_ROAS` + `roasAverageFloor` =\nTarget ROAS, `LOWEST_COST_WITH_BID_CAP` + `bidAmount` = Maximize Clicks with a\nCPC ceiling; `portfolioBidStrategyId` attaches a portfolio strategy instead\n(exclusive with `bidStrategy`). Setting the standard triplet on a campaign that\nis currently on a PORTFOLIO strategy is rejected: detach it in Google Ads\nfirst, since it is shared across campaigns.\n\nGoogle budget updates read the current budget before mutation. Shared budgets return\n409 unless allowSharedBudgetUpdate=true is explicitly supplied, because the change\naffects every campaign using that budget. Unknown sharing state also returns 409.\n\n`accountId` forwards the update straight to Meta for a campaign with zero ads,\nwhich would otherwise 404; the response then carries `updated: 0`.\n","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. On Google: LOWEST_COST_WITHOUT_CAP = Maximize Conversions, COST_CAP + bidAmount = Target CPA, LOWEST_COST_WITH_MIN_ROAS + roasAverageFloor = Target ROAS, LOWEST_COST_WITH_BID_CAP + bidAmount = Maximize Clicks with a CPC ceiling; portfolioBidStrategyId attaches a portfolio strategy instead."},"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."},"portfolioBidStrategyId":{"type":"string","pattern":"^\\d+$","description":"**Google only.** Attach an existing portfolio bid strategy (numeric id from GET /v1/ads/bid-strategies) instead of setting bidStrategy. Exclusive with bidStrategy."},"allowSharedBudgetUpdate":{"type":"boolean","default":false,"description":"Google only. Explicitly allow changing a shared campaign budget, affecting every campaign that uses it. Does not bypass an unknown sharing state."},"budget":{"type":"object","description":"Meta CBO or Google daily campaign budget, in whole currency units.","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."}}}}},"example":{"platform":"facebook","budget":{"amount":8,"type":"daily"}}}}},"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/AdCampaignBudget"},"budgetLevel":{"type":"string","enum":["campaign"]},"bidStrategy":{"$ref":"#/components/schemas/BidStrategy"},"bidAmount":{"type":"number"},"roasAverageFloor":{"type":"number"},"portfolioBidStrategyId":{"type":"string","description":"Google only. Echoed back, but NOT mirrored onto local Ad documents (no column for it yet)."},"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"},"403":{"description":"Returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"description":"Meta campaign is ABO, or the Google budget is shared without allowSharedBudgetUpdate=true, or sharing state cannot be verified. The account may also be inactive or need reconnection (code ads_connection_required). Reconnect it and read GET /v1/accounts for its current ID before retrying."},"501":{"description":"Operation not supported on this platform"}}},"delete":{"x-resource-group":"ads","operationId":"deleteAdCampaign","tags":["Ad Campaigns"],"x-platforms":["meta","google","tiktok","linkedin","pinterest","x","openai"],"summary":"Delete a campaign","description":"Deletes the whole campaign on the platform, cascading to its ad sets\nand ads. Locally, all Ad documents for this campaign are marked\n`status: cancelled`.\n\n**Empty campaigns.** A campaign with zero ads has no local Ad documents\nto resolve, so it is invisible to `/v1/ads/tree` and this endpoint would\n404. That state is produced by the two-step create flow (campaign, then\nads via `existingCampaignId`) whenever Meta rejects the ad step. To\ndelete such a shell, send `accountId` in the body: we skip the local\nlookup entirely and forward the delete to Meta. `accountId` is ignored\nwhen the campaign does have ads.\n","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"]},"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"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Operation not supported on this platform"}}}},"/v1/ads/campaigns/{campaignId}/negative-keywords":{"get":{"x-resource-group":"ads","operationId":"listCampaignNegativeKeywords","tags":["Ad Campaigns"],"x-platforms":["google"],"summary":"List campaign-level negative keywords","description":"Returns the campaign-level negative keywords (`campaign_criterion.negative`),\ndistinct from the ad-group-level negatives under `GET /v1/ads/keywords`. Cached\nfor the quota window (not synced to Postgres), and gated by the shared Google\nAds operations budget like every other on-demand Google surface. The response\ncarries `cachedAt` and `stale`, set when a quota-exhausted call falls back to\nthe last-good copy instead of a live read.\n\nThe platform is always discovered from the campaign itself; a non-Google\ncampaign returns 501 rather than 404, whether or not `platform` was passed.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"campaignId","in":"path","required":true,"schema":{"type":"string"},"description":"Platform campaign ID"},{"name":"platform","in":"query","schema":{"type":"string","enum":["facebook","instagram","tiktok","linkedin","pinterest","google","twitter","openai"]},"description":"Optional and NOT authoritative: the resolved campaign's own platform decides 200 vs 501, never this hint."}],"responses":{"200":{"description":"Campaign-level negative keywords","content":{"application/json":{"schema":{"type":"object","properties":{"keywords":{"type":"array","items":{"type":"object","properties":{"criterionId":{"type":"string"},"text":{"type":"string"},"matchType":{"type":"string","enum":["exact","phrase","broad"]}}}},"cachedAt":{"type":["string","null"],"format":"date-time","description":"When this list was fetched from Google. Null when it was never served from cache."},"stale":{"type":"boolean","description":"True when Google's daily API quota was exhausted and this is the last successful fetch, not a live read."}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Campaign not found"},"429":{"description":"Google Ads operations budget exhausted; retry later"},"501":{"description":"Only available on Google Ads campaigns"}}},"put":{"x-resource-group":"ads","operationId":"replaceCampaignNegativeKeywords","tags":["Ad Campaigns"],"x-platforms":["google"],"summary":"Replace campaign-level negative keywords","description":"Replaces the FULL set of campaign-level negative keywords (C.270): the desired\nlist is diffed against what Google already has, and the difference is applied\nas one `create`/`remove` mutate. Send an empty array to clear every campaign\nnegative.\n\nThe platform is always discovered from the campaign itself; a non-Google\ncampaign returns 501 rather than 404, whether or not `platform` was sent.\n","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":["keywords"],"properties":{"platform":{"type":"string","enum":["facebook","instagram","tiktok","linkedin","pinterest","google","twitter","openai"],"description":"Optional and NOT authoritative: the resolved campaign's own platform decides 200 vs 501, never this hint."},"keywords":{"type":"array","maxItems":1000,"items":{"$ref":"#/components/schemas/KeywordEntry"}}}}}}},"responses":{"200":{"description":"Campaign-level negative keywords replaced","content":{"application/json":{"schema":{"type":"object","properties":{"created":{"type":"integer","description":"Negative criteria newly created on Google"},"removed":{"type":"integer","description":"Negative criteria removed from Google"},"keywords":{"type":"array","description":"The full negative-keyword set after the replace","items":{"type":"object","properties":{"criterionId":{"type":"string"},"text":{"type":"string"},"matchType":{"type":"string","enum":["exact","phrase","broad"]}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Campaign not found"},"429":{"description":"Google Ads operations budget exhausted; retry later"},"501":{"description":"Only available on Google Ads campaigns"}}}},"/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\nconcurrently and the response contains a per-campaign result so a\nsingle bad row does not fail the whole batch.\n","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"},"403":{"description":"Returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."}}}},"/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\ntargeting by default (`deepCopy: true`). The copy is created paused\nso callers can review before launching.\n\nPer-platform implementation:\n- **Meta** uses the native `POST /{campaign-id}/copies` endpoint.\n- **TikTok** has no native copy primitive; Zernio walks the source\n  graph (`/v2/campaign/get/`, `/v2/adgroup/get/`, `/v2/ad/get/`) and\n  recreates each entity via the corresponding `/create/` endpoints,\n  carrying over budget / targeting / bid_type / bid_price /\n  deep_bid_type / creative fields. Spark Ad linkage (`tiktok_item_id`)\n  is preserved.\n- **LinkedIn** has no native copy primitive; Zernio walks the source\n  CampaignGroup → Campaigns → Creatives and recreates each entity,\n  carrying over `type` / `costType` / `unitCost` /\n  `optimizationTargetType` / `creativeSelection` / `objectiveType` /\n  `format` / `dailyBudget` / `totalBudget` / `targetingCriteria` /\n  `runSchedule` and every Creative's `content` object verbatim.\n  `statusOption: INHERITED_FROM_SOURCE` is evaluated **per entity**:\n  any Group / Campaign / Creative whose source is `ACTIVE` gets its\n  clone activated too. Duplicating an ACTIVE campaign with\n  `INHERITED_FROM_SOURCE` starts a second front of spend the moment\n  the clone activates. The safe default is `PAUSED`.\n\nThe new hierarchy is asynchronous to materialize in our DB, and we\ntrigger sync discovery automatically. Set `syncAfter: false` to\nskip and poll `/v1/ads/tree` on your own cadence.\n\nOther platforms return 501 Not Implemented.\n","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.\n"},"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"},"403":{"description":"Returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."},"404":{"description":"Source campaign not found"},"501":{"description":"Operation not supported on this platform"}}}},"/v1/ads/campaigns/{campaignId}/targeting":{"get":{"x-resource-group":"ads","operationId":"getCampaignTargeting","tags":["Ad Campaigns"],"x-platforms":["google"],"summary":"Read a Google campaign's device, location, and language targeting","description":"Google Ads compliance requires geo, language, budget, and bidding targeting\nset at creation to stay editable afterwards; this reads the campaign state\nso an integrator can build an editor around it. Cached for the quota window\n(10 minutes fresh, up to 7 days last-good), not always a live read. Google\nonly; every other platform returns 501.\n\n`devices` lists the device criteria the campaign carries, which depends on\nits channel: Search campaigns have MOBILE, DESKTOP and TABLET, Display\ncampaigns also have CONNECTED_TV. `bidModifier` is Google's bid adjustment\nfor that device, `null` when it has none, and `0` when the device is\nswitched off; `included` is false for exactly that case.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"campaignId","in":"path","required":true,"schema":{"type":"string"},"description":"Google platform campaign ID"},{"name":"platform","in":"query","schema":{"type":"string","enum":["google"]},"description":"Disambiguates when the same campaignId string exists on more than one connected platform."}],"responses":{"200":{"description":"Current campaign targeting","content":{"application/json":{"schema":{"type":"object","properties":{"devices":{"type":"array","items":{"type":"object","properties":{"device":{"type":"string","enum":["MOBILE","DESKTOP","TABLET","CONNECTED_TV"]},"included":{"type":"boolean"},"bidModifier":{"type":["number","null"],"description":"Google's bid adjustment for this device: null when it has none, 0 when the device is switched off, otherwise 0.1 to 10."}}}},"locations":{"type":"array","items":{"type":"object","properties":{"geoTargetId":{"type":"string","description":"Numeric id from Google's geoTargetConstants/{id}."},"negative":{"type":"boolean","description":"true = excluded location."},"name":{"type":["string","null"],"description":"Google's geo_target_constant.name, e.g. \"United States\"; null when the id could not be resolved."},"canonicalName":{"type":["string","null"],"description":"Google's geo_target_constant.canonical_name, e.g. \"California, United States\"; null when the id could not be resolved."},"type":{"type":["string","null"],"description":"Google's geo_target_constant.target_type, e.g. \"Country\", \"Region\", \"City\"; null when the id could not be resolved."},"countryCode":{"type":["string","null"],"description":"Google's geo_target_constant.country_code, an ISO 3166-1 alpha-2 code; null when the id could not be resolved."}}}},"languages":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string","description":"Google's language code (ISO 639-1, plus variants such as `zh_CN`). Empty when the campaign's language_constant id is not in Zernio's checked-in table."},"id":{"type":"string","description":"Google's languageConstants/{id} numeric id."},"name":{"type":"string"}}}},"cachedAt":{"type":["string","null"],"format":"date-time","description":"When this targeting was fetched from Google. Null when it was never served from cache."},"stale":{"type":"boolean","description":"True when Google's daily API quota was exhausted and this is the last successful fetch, not a live read."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access required (Ads add-on on legacy plans, included on usage-based plans)."},"404":{"description":"Campaign not found"},"501":{"description":"Only available on Google Ads campaigns"}}},"put":{"x-resource-group":"ads","operationId":"updateCampaignTargeting","tags":["Ad Campaigns"],"x-platforms":["google"],"summary":"Edit a Google campaign's device, location, or language targeting","description":"Google Ads compliance row M.10: geo and language targeting set at\ncreation must stay editable afterwards. Send at least one of `devices`,\n`locations`, `languages`; each provided field REPLACES that field's\nexisting criteria on the campaign (a full set, not a delta). Fields left\nout of the body are untouched. Google only; every other platform returns\n501.\n\n`devices` is the full set of device bid modifiers: a supported device you\nleave out is switched off with a bid modifier of 0, since Google cannot\nremove a device criterion. A device the campaign's channel does not carry,\nand a set that switches every device off, both return 422.\n\n`locations` accepts the same shapes as campaign creation: a bare array of\nISO country codes, or an object with `countries`/`regions`/`cities`/`zips`/`metros`\nkey lists (`key` from GET /v1/ads/targeting/search?dimension=geo). Negative\n(excluded) locations are left untouched by this endpoint. An empty location list\nreturns 400 instead of removing every criterion: a Google campaign with no location\ncriteria targets every country, so omit `locations` to leave targeting alone.\n\nThe removes and the creates go out in ONE Google `googleAds:mutate`, so a failed\nedit leaves the campaign's previous set intact rather than a half-applied one.\n\n`languages` is an array of Google's language codes (ISO 639-1, plus variants\nsuch as `zh_CN`); an unknown code returns 400.\n\nThe response includes the refreshed `devices`/`locations`/`languages` state\nread back from Google after the edit, and invalidates the cached copy\n`GET` on this campaign would otherwise keep serving.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"campaignId","in":"path","required":true,"schema":{"type":"string"},"description":"Google platform campaign ID"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["platform","targeting"],"properties":{"platform":{"type":"string","enum":["google"]},"targeting":{"type":"object","minProperties":1,"properties":{"devices":{"type":"array","items":{"oneOf":[{"type":"string","enum":["MOBILE","DESKTOP","TABLET","CONNECTED_TV"]},{"type":"object","required":["device","bidModifier"],"properties":{"device":{"type":"string","enum":["MOBILE","DESKTOP","TABLET","CONNECTED_TV"]},"bidModifier":{"type":"number","description":"Bid modifier for this device; devices without one use the campaign's base bid."}}}]},"description":"Devices to include. Devices not listed become excluded (negative) criteria, same contract as the existing devices-only edit."},"locations":{"description":"Bare country-code array, or the nested creation-time shape (countries/regions/cities/zips/metros).","oneOf":[{"type":"array","items":{"type":"string","description":"ISO 3166-1 alpha-2 country code."}},{"type":"object","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","items":{"type":"object","required":["key"],"properties":{"key":{"type":"string"},"name":{"type":"string"}}}},"zips":{"type":"array","items":{"type":"object","required":["key"],"properties":{"key":{"type":"string"},"name":{"type":"string"}}}},"metros":{"type":"array","items":{"type":"object","required":["key"],"properties":{"key":{"type":"string"},"name":{"type":"string"}}}}}}]},"languages":{"type":"array","items":{"type":"string"},"description":"Google's language codes (ISO 639-1, plus variants such as `zh_CN`), e.g. [\"en\", \"de\"]."}}}}}}}},"responses":{"200":{"description":"Targeting updated","content":{"application/json":{"schema":{"type":"object","properties":{"campaignId":{"type":"string"},"updated":{"type":"array","items":{"type":"string","enum":["devices","locations","languages"]},"description":"Which targeting fields were applied."},"devices":{"type":"array","items":{"type":"object","properties":{"device":{"type":"string","enum":["MOBILE","DESKTOP","TABLET","CONNECTED_TV"]},"included":{"type":"boolean"},"bidModifier":{"type":["number","null"],"description":"Always null on this read; see GET's description."}}}},"locations":{"type":"array","items":{"type":"object","properties":{"geoTargetId":{"type":"string","description":"Numeric id from Google's geoTargetConstants/{id}."},"negative":{"type":"boolean","description":"true = excluded location."},"name":{"type":["string","null"],"description":"Google's geo_target_constant.name; see GET's description."},"canonicalName":{"type":["string","null"],"description":"Google's geo_target_constant.canonical_name; see GET's description."},"type":{"type":["string","null"],"description":"Google's geo_target_constant.target_type; see GET's description."},"countryCode":{"type":["string","null"],"description":"Google's geo_target_constant.country_code; see GET's description."}}}},"languages":{"type":"array","items":{"type":"object","properties":{"code":{"type":"string"},"id":{"type":"string"},"name":{"type":"string"}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."},"404":{"description":"Campaign not found"},"501":{"description":"Only available on Google Ads campaigns"}}}},"/v1/ads/ad-sets":{"get":{"x-resource-group":"ads","operationId":"listAdSets","tags":["Ad Campaigns"],"x-platforms":["meta","google","tiktok","linkedin","pinterest","x"],"summary":"List ad sets","description":"Ad sets (Google ad groups) synced for the connection, optionally\nfiltered by platform and campaignId. Reads the `ad_sets` table\ndirectly, independent of the `ads` rollup GET /v1/ads/tree uses, so a\nnewly created standalone ad group with no ad yet (POST /v1/ads/ad-sets,\nGoogle only) is visible here even though it is invisible in the tree\nuntil an ad joins it via `adSetId` on POST /v1/ads/create. Returns at most 500\nrows, newest first.","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","schema":{"type":"string"},"description":"Account ID"},{"name":"campaignId","in":"query","schema":{"type":"string"},"description":"Platform campaign ID"},{"name":"platform","in":"query","schema":{"type":"string","enum":["facebook","instagram","tiktok","linkedin","pinterest","google","twitter","openai"]}}],"responses":{"200":{"description":"Ad sets","content":{"application/json":{"schema":{"type":"object","properties":{"adSets":{"type":"array","items":{"type":"object","properties":{"platformAdSetId":{"type":"string"},"platform":{"type":"string"},"adSetName":{"type":["string","null"]},"status":{"type":["string","null"]},"platformAdSetStatus":{"type":["string","null"]},"platformCampaignId":{"type":["string","null"]},"platformAdAccountId":{"type":"string"},"accountId":{"type":["string","null"]},"profileId":{"type":"string"},"currency":{"type":["string","null"]},"budget":{"type":["object","null"]},"isExternal":{"type":["boolean","null"]},"platformCreatedAt":{"type":["string","null"],"format":"date-time"}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access required (Ads add-on on legacy plans, included on usage-based plans)."}}},"post":{"x-resource-group":"ads","operationId":"createAdSet","tags":["Ad Campaigns"],"x-platforms":["google"],"summary":"Create a standalone ad group","description":"Google Ads compliance row C.190: creates an ad group WITHOUT an ad,\nunder an existing campaign. Ads join it later via `adSetId`\non POST /v1/ads/create. Google only; every other platform returns 501.\n\nCreated `PAUSED` unless `status: ACTIVE`. The new ad group has no ad\nyet, so it will not appear in GET /v1/ads/tree (built purely from `ads`\nrows) until one is added; use GET /v1/ads/ad-sets to see it in the\nmeantime.\n\n**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","platform","campaignId","name"],"properties":{"accountId":{"type":"string","description":"Zernio SocialAccount id owning the Google Ads connection."},"platform":{"type":"string","enum":["facebook","instagram","tiktok","linkedin","pinterest","google","twitter","openai"],"description":"Only \"google\" is implemented today; every other value returns 501."},"campaignId":{"type":"string","description":"Google platform campaign ID (numeric) the ad group is created under."},"name":{"type":"string","minLength":1,"maxLength":255},"status":{"type":"string","enum":["ACTIVE","PAUSED"],"default":"PAUSED"},"customerId":{"type":"string","description":"Numeric Google Ads customer id. Only required when the connection has more than one."}}}}}},"responses":{"201":{"description":"Ad group created","content":{"application/json":{"schema":{"type":"object","properties":{"adSetId":{"type":"string","description":"Platform id of the new ad group"},"campaignId":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Only supported on Google Ads"}}}},"/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`),\nvia Meta's native `POST /{adset-id}/copies`. The copy is created paused so callers can\nreview before launching. `campaignId` retargets the copy into another campaign; omitted\n= the source's own campaign. The new hierarchy materializes asynchronously, and sync\ndiscovery 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"},"403":{"description":"Returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."},"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\npaused. `adSetId` retargets the copy into another ad set; omitted = the source's own ad\nset. Accepts the Zernio ad id or the platform ad id. Sync discovery is triggered\nautomatically (`syncAfter: false` to skip). Creative settings returned by Meta,\nincluding explicit promotion metadata and creativeFeatures, are preserved when the\nnative copy requires a creative rebuild. Metadata Meta does not return cannot be recovered.","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"},"403":{"description":"Returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."},"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":"Get live ad-set details","description":"Reads the ad set live from Meta, returned verbatim. The default projection includes\n`learning_stage_info` (learning-phase status: LEARNING / SUCCESS / FAIL / WAIVING; Meta\nomits its `status` key on paused ad sets), delivery settings, budgets, schedule and\ntargeting. `fields` is a raw-passthrough override; unknown fields return Meta's 400\nverbatim.","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","example":"id,status,ads.limit(100){id,name,status,issues_info}"},"description":"Comma-separated Graph field override. Supports nested {} projections and Graph field modifiers, so a nested edge can be paged explicitly: without a .limit() modifier the expansion runs at the Meta default page size and the tail is dropped silently."}],"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; the message carries Meta's error"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Only supported on Meta (facebook/instagram)"}}},"put":{"x-resource-group":"ads","operationId":"updateAdSet","tags":["Ad Campaigns"],"x-platforms":["meta","google","tiktok","linkedin","pinterest","x","openai"],"summary":"Update an ad set","description":"Ad-set-level writes. Use this for ABO budget updates, ad-set-scoped\npause/resume, bid-strategy edits, Meta value-rule-set attach/detach, and\nMeta-only post-launch delivery settings via `platformSpecificData`. At\nleast one updatable field is required.\n\nValue rule sets (Meta only, see `/v1/ads/value-rule-sets`):\n- ATTACH or REPLACE: send `valueRuleSetId`. Attachment is driven by the id's\n  presence, so `valueRulesApplied: true` is optional. Sending a different id\n  replaces the previous association; there is no separate replace call.\n- DETACH: send `valueRulesApplied: false` and OMIT `valueRuleSetId`.\n- Sending `valueRulesApplied: false` TOGETHER with `valueRuleSetId` returns 400\n  `mutually_exclusive_fields`. This is deliberate: Meta attaches the rule set\n  whenever `value_rule_set_id` is present, even with `value_rules_applied` false,\n  so echoing stored state while asking to detach would silently keep the bid\n  adjustments live.\n- Eligibility: only ad sets on `LOWEST_COST_WITHOUT_CAP` or `COST_CAP`. Meta\n  rejects the rest server-side.\n- Read back with `GET /v1/ads/ad-sets/{adSetId}?fields=value_rule_set_id`. Meta\n  does not document `value_rules_applied` as a readable ad-set field, so the\n  boolean cannot be read back.\n\nBid strategy compatibility (per Meta's spec):\n- `LOWEST_COST_WITHOUT_CAP`: no `bidAmount`, no `roasAverageFloor`.\n- `LOWEST_COST_WITH_BID_CAP` / `COST_CAP`: `bidAmount` REQUIRED (whole currency units).\n- `LOWEST_COST_WITH_MIN_ROAS`: `roasAverageFloor` REQUIRED (decimal multiplier, e.g. 2.0 = 2.0x ROAS).\n- Meta only: send `bidAmount` WITHOUT `bidStrategy` to change the cap amount on an ad set\n  under a COST_CAP / LOWEST_COST_WITH_BID_CAP parent campaign, leaving the strategy itself\n  (inherited from the campaign) untouched. `roasAverageFloor` without `bidStrategy` is\n  rejected (it has no meaning outside LOWEST_COST_WITH_MIN_ROAS).\n\nDelivery settings are validated by Meta against the campaign objective;\nincompatible combinations (e.g. a billingEvent the optimization goal\ndoesn't allow) surface as 400s from Meta.\n\nWhen updating `budget` on an ABO campaign: if the parent campaign is\nCBO, the response is 409 with code BUDGET_LEVEL_MISMATCH. Route to\nPUT /v1/ads/campaigns/{campaignId} instead.\n","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":"Writes the ad set's own on/off switch (Meta: `configured_status`) on Meta and LinkedIn, whatever delivery status its ads report. 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.\nSupported on Meta (facebook, instagram), TikTok, and OpenAI. On TikTok the\nMeta-style enum is mapped to bid_type / bid_price / deep_bid_type\nautomatically. On OpenAI, LOWEST_COST_WITH_BID_CAP and COST_CAP both map to\nthe ad group's `bidding_config.max_bid_micros` (one knob covers both);\nLOWEST_COST_WITH_MIN_ROAS is rejected with 422 (OpenAI has no ROAS-based\nbidding). Other platforms (linkedin, pinterest, google, twitter) return 501\nNot Implemented when bidStrategy is set.\n"},"bidAmount":{"type":"number","description":"Bid cap in WHOLE currency units (USD: 5 = $5.00; JPY: 100 = ¥100). Required when\nbidStrategy is LOWEST_COST_WITH_BID_CAP or COST_CAP. Internally converted to Meta's\nsmallest-denomination integer, or (on OpenAI) to micros (× 1,000,000). Meta only:\nmay be sent alone, WITHOUT bidStrategy, to update the cap amount on an ad set whose\nparent campaign is COST_CAP or LOWEST_COST_WITH_BID_CAP (the strategy is inherited\nfrom the campaign and is left untouched).\n"},"roasAverageFloor":{"type":"number","description":"Minimum ROAS as a decimal multiplier (2.0 = 2.0x). Required when bidStrategy is\nLOWEST_COST_WITH_MIN_ROAS. Sent to Meta as `bid_constraints.roas_average_floor` × 10000.\nNot supported on OpenAI (422).\n"},"valueRuleSetId":{"type":"string","pattern":"^\\d+$","description":"Meta only (other platforms return 501). Value rule set to attach to this ad\nset, from `/v1/ads/value-rule-sets`. Sending a different id replaces the\ncurrent association. To DETACH, send `valueRulesApplied: false` and omit\nthis field.\n"},"valueRulesApplied":{"type":"boolean","description":"Meta only (other platforms return 501). `false` DETACHES the ad set's value\nrule set and must be sent WITHOUT `valueRuleSetId`; the combination returns\n400. `true` is optional when attaching, since attachment is driven by\n`valueRuleSetId`, and requires it to be present.\n"},"platformSpecificData":{"type":"object","description":"Platform-specific post-launch delivery settings. The platform is implied by the\n`platform` body param. Meta only; other platforms return 400. Unknown keys are rejected.\n","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)."},"dailyMinSpendTarget":{"type":"number","description":"Meta `daily_min_spend_target`: the least this ad set should spend per day, in whole\ncurrency units of the ad account. It reserves a share of a CAMPAIGN budget for one ad\nset, so it requires a campaign using Advantage campaign budget (CBO). On an ad set\nthat owns its budget (ABO) this returns 409. Move the budget to the campaign with\n`PUT /v1/ads/campaigns/{campaignId}` first. Meta treats it as a target, not a\nguarantee, and rejects the combined minimum of a campaign's ad sets going over the\ncampaign budget.\nMutually exclusive with `lifetimeMinSpendTarget` (400): the flavour must match the\ncampaign budget type, a daily budget takes a daily target.\nRead it back with `GET /v1/ads/ad-sets/{adSetId}?fields=daily_min_spend_target`.\n"},"lifetimeMinSpendTarget":{"type":"number","description":"Meta `lifetime_min_spend_target`: the lifetime-budget flavour of\n`dailyMinSpendTarget`, in whole currency units. Send this one when the campaign\nbudget is a lifetime budget. Same rules and same rejections.\n"},"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"],"description":"The status written to the ad set. Absent when nothing was written (see statusMessage)."},"statusUpdated":{"type":"integer","description":"Number of ads whose own stored status changed alongside the ad set switch"},"statusSkipped":{"type":"integer","description":"Number of ads whose own status was left as it was"},"statusSkippedReasons":{"type":"array","items":{"type":"string"},"description":"Why each group of ads was skipped"},"statusMessage":{"type":"string","description":"Present only where the platform has no ad-set switch and no child ad was actionable; `status` is then absent because nothing was written"},"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"},"403":{"description":"Returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."},"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)"}}},"delete":{"x-resource-group":"ads","operationId":"deleteAdSet","tags":["Ad Campaigns"],"x-platforms":["meta","google","tiktok","linkedin","pinterest","x"],"summary":"Delete an ad set","description":"Deletes the ad set on the platform, cascading to its ads only (never the\ncampaign). Locally, every Ad document under the ad set is marked\n`status: cancelled`.\n\nDelete is soft on platforms that have no hard delete: LinkedIn moves the\ncampaign to `PENDING_DELETION`, Pinterest archives the ad group, and X\nsoft-flags the line item. Google removes the ad group. All remain readable\nfor reporting.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"adSetId","in":"path","required":true,"schema":{"type":"string"},"description":"Platform ad set ID"}],"responses":{"200":{"description":"Ad set deleted","content":{"application/json":{"schema":{"type":"object","properties":{"deleted":{"type":"boolean"},"adCount":{"type":"integer","description":"Local Ad documents marked cancelled"}}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Ad set not found"},"501":{"description":"Operation not supported on this platform"}}}},"/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\nover PUT /v1/ads/ad-sets/{adSetId} for callers that only want the\nstatus toggle and prefer a symmetric URL to\n/v1/ads/campaigns/{campaignId}/status.\n\nOn Meta and LinkedIn this writes the ad set's own on/off switch\n(Meta: `configured_status`), whatever delivery status its ads report:\nan ad still in review does not block resuming its ad set. The echoed\n`status` is the confirmation that it landed. Where the platform has no\nad-set switch (TikTok and others) the toggle is emulated by flipping the\nchild ads; a call with no actionable ad then writes nothing and returns a\n`message` with no `status`.\n\n`updated` / `skipped` describe only the ads whose own stored status\nCHANGED alongside the switch, so `updated: 0` is a normal successful\nresponse. See `skippedReasons` for which of the three cases applies\n(terminal, already in the target state, or switched on but not yet\ndelivering).\n\nA campaign created paused needs its campaign resumed as well: pair this\nwith PUT /v1/ads/campaigns/{campaignId}/status.\n","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":{"status":{"type":"string","enum":["active","paused"],"description":"The status written to the ad set. Absent when nothing was written (see message)."},"updated":{"type":"integer","description":"Number of ads whose own stored status changed too. 0 is normal on a resume whose ads are all awaiting the platform."},"skipped":{"type":"integer","description":"Number of ads whose own status was left as it was"},"skippedReasons":{"type":"array","items":{"type":"string"},"description":"Why each group of ads was skipped"},"message":{"type":"string","description":"Present only where the platform has no ad-set switch and no child ad was actionable"}}}}}},"400":{"description":"Invalid input"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."},"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.\nUses a two-stage aggregation: ads are grouped into ad sets, then ad sets into campaigns.\nMetrics are computed over an optional date range, then rolled up from ad level to ad set\nand campaign levels. Pagination is at the campaign level. Ads without a campaign or ad set\nID are grouped into synthetic \"Ungrouped\" buckets.\nIf no date range is provided, defaults to the last 90 days. Date range is capped at 730 days max.\n\nPass `timeIncrement=1` to also get a daily breakdown: each node gains a `daily[]` array of\nper-day metrics (same fields as the aggregated `metrics`) in the same call. Use `dailyLevel`\n(`campaign` default, or `adset` / `ad`) to choose which levels carry the series. This replaces\ncalling the tree once per day for per-campaign daily trends.\n\n**Deleted objects stay in the tree.** Deleting an ad or a campaign is a soft delete: the Ad\ndocuments move to `status: cancelled` and are kept indefinitely, so their historical spend\nstill counts toward the metrics of any date range they fall in. There is no pruning job and\nno retention window. Filter on `status` if your view should hide them, but do that after\nreading the totals, not before.\n","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":"One or more platform ad account IDs to scope the tree to (agency profiles connect a whole Business Manager but a team usually cares about a subset). Comma-separate for multiple (`?adAccountId=act_1,act_2,act_3`); single value keeps its old shape. Max 50 accounts per request; the plural aliases `adAccountIds` and `platformAdAccountIds` are rejected with a 400 to stop them from silently returning the unfiltered fleet."},{"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":"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). On its own it affects only the spend/impression numbers overlaid on each node, not which campaigns are returned. Pass `hasDelivery` or `minSpend` to also filter the campaign set to this window. 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":"hasDelivery","in":"query","schema":{"type":"boolean"},"description":"Return only campaigns that delivered between `fromDate` and `toDate`: spend above zero, or impressions served at zero spend. Unlike `status`, which reads a campaign's CURRENT state, this filters on what happened inside the window, so a campaign that spent then and is paused today is still returned. Filters the campaign set itself, so `pagination.total` counts only matching campaigns."},{"name":"minSpend","in":"query","schema":{"type":"number","minimum":0},"description":"Return only campaigns whose spend between `fromDate` and `toDate` reaches this amount. Expressed in each campaign's OWN currency (the `currency` field on the campaign node): spend is stored per ad account in its native currency and one response can span several. Implies `hasDelivery`; `minSpend=0` applies no filter."},{"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":{"$ref":"#/components/schemas/AdTreeResponse"}}}},"202":{"description":"Historical data is incomplete and backfill remains pending.","headers":{"Retry-After":{"$ref":"#/components/headers/BackfillRetryAfter"}},"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AdTreeResponse"},{"type":"object","required":["backfillPending"],"properties":{"backfillPending":{"type":"boolean","description":"Always true on this response. Part of the requested range is still being backfilled; retry until the request returns 200."}}}]}}}},"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\ntime series, one row per calendar day in the requested range. Use this for\ndashboards that draw a daily-spend or daily-conversions chart, instead of\ncalling `/v1/ads/tree` once per day.\n\n`accountId` is required. The lookup is sibling-expanded so passing the `metaads`\nID also includes ads under the linked `facebook` / `instagram` posting account\n(and vice-versa), the same convention as `/v1/ads/tree` and `/v1/ads`.\n\nDate range defaults to the last 90 days. Capped at 730 days. Ranges older\nthan the ingested history return a `202` immediately with the covered part\nand `backfillPending: true` while the rest is backfilled in the background;\nrepeat the request shortly until it returns 200 with full data.\n\nWith adAccountId set to a Google customer id this is the customer-level performance report (clicks, cost, impressions, conversions, all conversions per day).\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"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":{"$ref":"#/components/schemas/AdsTimelineResponse"}}}},"202":{"description":"Historical data is incomplete and backfill remains pending.","headers":{"Retry-After":{"$ref":"#/components/headers/BackfillRetryAfter"}},"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AdsTimelineResponse"},{"type":"object","required":["backfillPending"],"properties":{"backfillPending":{"type":"boolean","description":"Always true on this response. Part of the requested range is still being backfilled; retry until the request returns 200."}}}]}}}},"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.\nGoogle Search ads include current creative.headlines, creative.descriptions and creative.finalUrls,\npreserving pinnedField. Top-level cachedAt and stale report cache freshness. Google mutations invalidate this read.\nRSA enrichment requires a stored advertisingChannelType of SEARCH. Ads with an unknown or other channel\nreturn their stored details without a Google read. If RSA enrichment fails, the stored ad is returned\nwith HTTP 200 and without cache metadata.\n\nThe `{adId}` path segment accepts any identifier dialect Zernio indexes for the ad:\n- the Zernio internal `_id` (24-char hex)\n- Meta's numeric `platformAdId` (the value shipped in `comment.received` webhooks as `comment.ad.id`)\n- the creative's `effective_object_story_id` (`{pageId}_{postId}` shape, Facebook side)\n- the creative's `effective_instagram_media_id` (Instagram side)\n\nAny of the four resolve to the same ad. Caller doesn't need a translation step.\n`creative.creativeFeatures` holds the stored requested settings, which do not confirm\nplatform application.\n","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.\n"}],"responses":{"200":{"description":"Ad details","content":{"application/json":{"example":{"ad":{"platform":"google","creative":{"headlines":[{"text":"Social Media API","pinnedField":"HEADLINE_1"},{"text":"Schedule Your Posts"},{"text":"Build With Zernio"}],"descriptions":[{"text":"Connect social accounts with one API.","pinnedField":"DESCRIPTION_1"},{"text":"Build social publishing into your application."}],"finalUrls":["https://zernio.com"]}},"cachedAt":null,"stale":false},"schema":{"type":"object","properties":{"ad":{"$ref":"#/components/schemas/Ad"},"cachedAt":{"type":["string","null"],"format":"date-time","description":"Google RSA details cache timestamp."},"stale":{"type":"boolean","description":"Whether Google RSA details use the last successful cached response."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"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\nare propagated to the platform.\n\nPer-platform support:\n- **Meta** (Facebook + Instagram): all fields supported.\n- **TikTok**: status, budget, targeting (via `/v2/adgroup/update/`), and creative\n  (via `/v2/ad/update/` patch-style: `headline` is ignored, `body` becomes `ad_text`).\n- **Google**: status, budget, KEYWORD edits via `targeting.keywords` /\n  `targeting.negativeKeywords`, DEVICE bid adjustments via `targeting.devices`,\n  LOCATION edits via `targeting.locations` (or the equivalent top-level\n  `targeting.countries` / `regions` / `cities` / `zips` / `metros`), and LANGUAGE\n  edits via `targeting.languages`.\n  Each list you send becomes the FULL new set of its kind (criteria not in the\n  list are removed, except devices, which Google cannot remove and which are\n  switched off with a bid modifier of 0 instead); a kind left out is untouched.\n  Any other `targeting` field\n  returns 400: Google cannot mutate it post-create without recreating\n  the campaign. Creative edits are dispatched on the ad's `advertisingChannelType`,\n  and every supported field replaces a whole set; a field you omit is preserved.\n  - **Search**: top-level `headlines`, `descriptions` and `finalUrls`. Use 3-15 headlines\n    (1-30 characters) and 2-4 descriptions (1-90 characters). Omit an asset to remove it;\n    omit pinnedField on an included asset to unpin it. Updates do not pad or truncate text.\n    The legacy creative fields remain unsupported.\n  - **Display**: top-level `headlines` (1-5, no pinnedField, display ads have no pinned\n    positions), `descriptions` (1-5) and `finalUrls`, plus `creative.longHeadline`,\n    `creative.businessName`, `creative.imageUrl` (the landscape marketing image) and\n    `creative.squareImageUrl`. Each image URL is uploaded as a new Google asset and the ad\n    is pointed at it; Google assets are immutable, so the previous asset stays in the\n    account's asset library.\n  - **Performance Max**: top-level `assetGroup`, which swaps asset roles on the ad's asset\n    group. The other creative fields return 422 for this channel, and `assetGroup` returns\n    422 on any other channel.\n- **LinkedIn**: status, budget, targeting (countries or regions, excludedLocations (countries),\n  the B2B facets, and audience segments; applied to the LinkedIn Campaign via\n  PARTIAL_UPDATE, and REPLACES the campaign's entire targetingCriteria, not a merge),\n  and creative (uploads new media, creates a replacement inline creative on the same\n  campaign, pauses the old one).\n- **Pinterest / X / OpenAI Ads**: status + budget only. Sending\n  `targeting` or `creative` returns 501 with code `unsupported_platform_operation`.\n  OpenAI Ads budget is lifetime-only (see `budget.type` below).\n\n**Google location and language replacement:** locations, languages and devices are\ncampaign-level criteria on Google, so these edits apply to every ad group and ad in\nthe ad's campaign. Send the complete list you want to keep. Zernio diffs it against\nthe campaign's live criteria and sends the removes and the creates in ONE\n`googleAds:mutate`, so the campaign is never left with a half-applied set; criteria\nalready in the list keep their criterion ID and history. Excluded (negative)\nlocations are left untouched. An empty location list returns 400 (a Google campaign\nwith no location criteria targets every country, which is never what \"remove my\nlocations\" means, so omit the field instead). Send either `targeting.locations` or the\ntop-level geo fields, not both: mixing them returns 400.\n\n**Google radius targeting:** `customLocations` is editable and is replaced the same\nway, but as its OWN set. Google models a place (LOCATION) and a point plus radius\n(PROXIMITY) as different criterion types, so the two are independent: sending\n`customLocations` replaces every radius and leaves the cities and countries alone,\nand sending places replaces those and leaves the radius alone. Send\n`customLocations: []` to drop radius targeting entirely. A circle you re-send\nunchanged keeps its criterion ID rather than being removed and recreated.\n\n**Google keyword replacement:** These edits affect the ad's entire ad group,\nincluding sibling ads. Positive (`targeting.keywords`) and negative\n(`targeting.negativeKeywords`) sets are independent: omit a field to leave\nthat set unchanged, or send `[]` to remove every keyword of that kind.\n\nZernio compares each supplied set with Google's live criteria by\ncase-insensitive keyword text and match type. A matching criterion is left\nuntouched, retaining its criterion ID, enabled/paused status, keyword-level\nbid overrides, labels, and criterion-associated history/statistics. Zernio\ndoes not reset its quality score; Google continues to calculate scores and\nstatistics normally. Text comparison does not trim whitespace.\n\nA bare string or an object without `matchType` means `broad`, not the\nexisting criterion's match type. For example, resending an existing\n`{ \"text\": \"plumber\", \"matchType\": \"exact\" }` preserves it; sending\n`\"plumber\"` instead removes that EXACT criterion and requests a BROAD one.\nChanging text or match type removes criteria no longer requested and\ncreates any missing criteria. New criteria get new IDs and do not inherit\nremoved criteria's bid overrides, labels, or history. Historical reporting\nfor a removed criterion is not transferred to its replacement.\n\nTo add keywords without replacing a set, use\n[POST /v1/ads/keywords](https://docs.zernio.com/ad-campaigns/add-ad-keywords).\nUse `PATCH /v1/ads/keywords/{keywordId}` to pause/enable one keyword, or\n`DELETE /v1/ads/keywords/{keywordId}` to remove it.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"adId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"examples":{"googleRsa":{"summary":"Replace and pin Google RSA text.","value":{"headlines":[{"text":"Social Media API","pinnedField":"HEADLINE_1"},{"text":"Schedule Your Posts"},{"text":"Build With Zernio"}],"descriptions":[{"text":"Connect social accounts and schedule posts with the Zernio API.","pinnedField":"DESCRIPTION_1"},{"text":"Build social publishing into your application."}],"finalUrls":["https://zernio.com"]}},"googlePmaxAssets":{"summary":"Swap the headlines and the landscape image on a Performance Max asset group.","value":{"assetGroup":{"headlines":["Schedule posts","One social API","Build with Zernio"],"images":{"landscape":["https://example.com/new-landscape.png"]}}}},"googleDisplayCreative":{"summary":"Replace the text and the image on a Google Display ad.","value":{"headlines":[{"text":"Schedule posts"},{"text":"One social API"}],"descriptions":[{"text":"Publish and manage social content through one API."}],"creative":{"longHeadline":"Schedule social content from your app with Zernio","imageUrl":"https://example.com/new-landscape.png","squareImageUrl":"https://example.com/new-square.png"}}},"metaCreativeFeatures":{"summary":"Opt a Meta creative out of an Advantage+ enhancement.","value":{"creative":{"creativeFeatures":{"auto_promotion_tag":"OPT_OUT"}}}}},"schema":{"type":"object","properties":{"headlines":{"type":"array","minItems":1,"maxItems":15,"items":{"$ref":"#/components/schemas/GoogleRsaHeadline"},"description":"Google Search and Display only. Replaces the complete headline list. Search takes 3-15, Display 1-5 and rejects pinnedField; the count is checked once the ad's channel is known. No padding or truncation on update."},"descriptions":{"type":"array","minItems":1,"maxItems":5,"items":{"$ref":"#/components/schemas/GoogleRsaDescription"},"description":"Google Search and Display only. Replaces the complete description list. Search takes 2-4, Display 1-5 and rejects pinnedField. No padding or truncation on update."},"finalUrls":{"type":"array","minItems":1,"items":{"type":"string","format":"uri"},"description":"Google Search and Display only. Replaces final URLs. Omitted lists stay unchanged. For Performance Max use assetGroup.finalUrl."},"assetGroup":{"allOf":[{"$ref":"#/components/schemas/GooglePmaxAssetGroupUpdate"}],"description":"Google Performance Max only. Replaces whole asset roles on the ad's asset group. Returns 422 on any other platform or channel."},"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 (demographics/interests), Google (keyword and device\nbid adjustment edits only), and LinkedIn (countries or regions required).\nPinterest / X return 501.\n","properties":{"keywords":{"type":"array","description":"Google only. The FULL desired set of positive keywords for the entire ad group.\nOmit to leave positives unchanged; [] removes all positives. Negatives are independent.\nEntries are strings (BROAD) or { text, matchType } with matchType exact | phrase | broad;\nan omitted matchType also defaults to BROAD. Matching case-insensitive text AND match type\nretains the existing criterion ID, status, bid overrides, labels and history without a mutation.\nA changed text or match type uses remove/create, without transferring the old criterion's\nattributes or history. See Google keyword replacement above for an EXACT-to-BROAD example.\nMirrored to GET /v1/ads/keywords immediately.\n","items":{"oneOf":[{"type":"string"},{"type":"object","required":["text"],"properties":{"text":{"type":"string","maxLength":80},"matchType":{"type":"string","enum":["exact","phrase","broad"]}}}]}},"negativeKeywords":{"type":"array","description":"Google only. The FULL desired set of negative keywords for the entire ad group,\nindependent of positives. Omit to leave negatives unchanged; [] removes all negatives.\nUses the same text/match-type identity and preservation contract as keywords above.\nStrings and objects without matchType default to BROAD, so resending an EXACT or PHRASE\nnegative as a bare string requests a different criterion. Campaign negatives are separate:\nuse /v1/ads/campaigns/{campaignId}/negative-keywords to manage those.\n","items":{"oneOf":[{"type":"string"},{"type":"object","required":["text"],"properties":{"text":{"type":"string","maxLength":80},"matchType":{"type":"string","enum":["exact","phrase","broad"]}}}]}},"devices":{"type":"array","description":"Google only. The FULL new set of device bid modifiers for the campaign. Entries are a device name alone (targeted, bid modifier reset to 1) or { device, bidModifier }. A supported device you leave out is switched off, written as a bid modifier of 0. Google never removes a device criterion, so an excluded device reads back as bidModifier 0 rather than disappearing, and a set that switches every device off returns 422. Which devices a campaign carries depends on its channel: Search campaigns have MOBILE, DESKTOP and TABLET, Display campaigns also have CONNECTED_TV, and sending a device the campaign does not carry returns 422.","items":{"oneOf":[{"type":"string","enum":["MOBILE","DESKTOP","TABLET","CONNECTED_TV"]},{"type":"object","required":["device"],"properties":{"device":{"type":"string","enum":["MOBILE","DESKTOP","TABLET","CONNECTED_TV"]},"bidModifier":{"type":"number","minimum":0,"maximum":10,"description":"Google device bid modifier. 0 switches the device off (minus 100%); otherwise 0.1 to 10 (minus 90% to plus 900%). Google rejects any value between 0 and 0.1."}}}]}},"ageMin":{"type":"integer","minimum":13,"maximum":65},"ageMax":{"type":"integer","minimum":13,"maximum":65},"countries":{"type":"array","items":{"type":"string"},"description":"ISO 3166-1 alpha-2 codes. On Google this is the FULL new country set for the campaign (same contract as `locations`); on LinkedIn it replaces the campaign's geo criteria."},"locations":{"description":"Google and LinkedIn. The FULL new location set for the campaign. Bare country-code array, or an object with countries/regions/cities/zips/metros key lists (`key` from GET /v1/ads/targeting/search?dimension=geo). Equivalent to the top-level geo fields; sending both returns 400. Empty returns 400, `customLocations` returns 422.","oneOf":[{"type":"array","items":{"type":"string","description":"ISO 3166-1 alpha-2 country code."}},{"type":"object","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","items":{"type":"object","required":["key"],"properties":{"key":{"type":"string"},"name":{"type":"string"}}}},"zips":{"type":"array","items":{"type":"object","required":["key"],"properties":{"key":{"type":"string"},"name":{"type":"string"}}}},"metros":{"type":"array","items":{"type":"object","required":["key"],"properties":{"key":{"type":"string"},"name":{"type":"string"}}}}}}]},"languages":{"type":"array","items":{"type":"string"},"description":"Google only. The FULL new language set for the campaign, as Google language codes (ISO 639-1, plus variants such as `zh_CN`). An unknown code returns 400."},"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 or patch the ad's creative. Meta, TikTok, and LinkedIn.\n\n- **Meta**: patch-style. Pass any subset: fields you omit are preserved from the\n  live creative, including media (`image_hash`/`video_id` are reused, no re-upload)\n  and `url_tags`. Sending the full set (`headline`, `body`, `callToAction`,\n  `linkUrl`, `imageUrl`) rebuilds the creative from scratch instead. Partial\n  patching reads the live `object_story_spec`, which Meta strips on SHARE /\n  page-post / dark / asset_feed creatives. Those return 422 asking for the full\n  set. A `videoUrl`/`videoId` on an image creative is a type change and also\n  needs the full set. `existingCreativeId` repoints the ad at a creative from\n  GET /v1/ads/creatives and ignores every other field. Meta creatives are\n  immutable, so any change creates a new creative and repoints the ad; the old\n  creative is retained on the ad account for historical reporting.\n  `creativeFeatures` is Meta-only. Omitted settings are preserved from the\n  live creative, including full rebuilds. A supplied creativeFeatures map\n  overrides individual existing keys.\n- **TikTok**: patch-style. Pass any subset; `headline` is ignored (TikTok creatives\n  have no headline slot). `body` becomes the in-feed `ad_text`; `linkUrl` becomes\n  `landing_page_url`; `videoUrl` triggers a fresh upload. `description`, `videoId`\n  and `existingCreativeId` are Meta-only and return 400.\n- **LinkedIn**: requires new media (image via `imageUrl` or video via `videoUrl`);\n  a text-only creative update returns 400. Uploads the media, creates a new inline\n  media creative on the same campaign, and pauses the old creative (best-effort).\n  The old creative is retained for historical reporting. `videoId` and\n  `existingCreativeId` are Meta-only and return 400.\n","properties":{"promotion":{"$ref":"#/components/schemas/MetaPromotion","description":"Not supported. Meta validates creative_sourcing_spec.promotion_metadata_spec on the create call and then discards it, so a Promotion set through the Marketing API never reaches the creative. Any object is rejected with 400 invalid_field_value. Send null or omit the field, and set the Promotion on the ad in Ads Manager. Verified on 2026-09-11 across Graph v19.0 to v25.0 and every write path."},"creativeFeatures":{"$ref":"#/components/schemas/MetaCreativeFeatures"},"headline":{"type":"string","description":"Meta and LinkedIn (TikTok has no headline slot)"},"longHeadline":{"type":"string","minLength":1,"maxLength":90,"description":"Google Display only. Replaces the responsive display ad's long headline."},"businessName":{"type":"string","minLength":1,"maxLength":25,"description":"Google Display only. Replaces the responsive display ad's business name."},"squareImageUrl":{"type":"string","format":"uri","description":"Google Display only. Uploaded as a new square (1:1) marketing image asset that replaces the current one."},"body":{"type":"string"},"description":{"type":"string","maxLength":255,"description":"Link description slot (Meta `link_data.description` / `video_data.link_description`, LinkedIn creative description)."},"callToAction":{"type":"string"},"linkUrl":{"type":"string","format":"uri"},"imageUrl":{"type":"string","format":"uri"},"videoUrl":{"type":"string","format":"uri"},"videoId":{"type":"string","description":"Meta only. Reuse an already-uploaded ad video (from POST /v1/ads/videos or GET /v1/ads/videos) instead of re-uploading via videoUrl."},"existingCreativeId":{"type":"string","description":"Meta only. Repoint the ad at an existing library creative (from GET /v1/ads/creatives); all other creative fields are ignored."}}},"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, budget below minimum, a LinkedIn creative update without imageUrl or videoUrl, a LinkedIn targeting update without countries or regions, or a Google targeting update that is unsupported, empty, mixes locations with the top-level geo fields, or names an unknown country or language code"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"The ad has no campaign or ad group on the platform yet, the Google targeting edit asks for something that is create-only (`locations.customLocations`), or a creative field the ad's channel cannot carry: assetGroup on a non-Performance-Max ad, a Google Display field on a Search ad, a pinnedField on a Display headline, or any Google-only field on another platform. A Google creative edit that cannot reach Google at all (the ad has no `platformAdId`, or its ad account cannot be loaded) also returns 422 rather than a 200 that changed nothing."},"429":{"description":"Meta admits one write per 30 seconds to a metered object, ad creatives above all. Zernio waits out two of those windows and replays the call before surfacing this, so it only appears when the object is being edited faster than that. Retry in 30 seconds."},"501":{"description":"targeting or creative not supported on the platform (supported on Meta, TikTok, and LinkedIn)"},"502":{"description":"Meta accepted the request then failed to produce the media (upload session, chunk transfer, processing timeout, or a response with no image hash). Inspect `platformError.reason`."}}},"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\ncampaign (so sibling ads keep running). Thin wrapper over the `status`\nfield of PUT /v1/ads/{adId}, for callers that want a URL symmetric to\n/v1/ads/campaigns/{campaignId}/status and /v1/ads/ad-sets/{adSetId}/status.\n\n`{adId}` accepts the same identifier dialects as GET/PUT /v1/ads/{adId}\n(Zernio hex `_id`, Meta numeric `platformAdId`, or the creative's\neffective story/media IDs). `platform` is inferred from the ad, so it's\nnot required in the body. Ads in terminal statuses (rejected, completed,\ncancelled) and no-op flips (already in the target state) are skipped.\n","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"},"403":{"description":"Returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."},"404":{"description":"Ad not found"}}}},"/v1/ads/campaigns/{campaignId}/assets":{"get":{"operationId":"listCampaignAssets","summary":"List campaign assets","x-resource-group":"ads","tags":["Ad Campaigns"],"x-platforms":["google"],"description":"Lists directly attached Google assets. Fresh reads are cached for 10 minutes; exhausted quota may return the last successful read with stale=true. Inherited assets are not included.","security":[{"bearerAuth":[]}],"parameters":[{"name":"campaignId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Numeric Google platform id."},{"name":"accountId","in":"query","required":true,"schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."}},{"name":"customerId","in":"query","schema":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."}}],"responses":{"200":{"description":"Assets returned.","content":{"application/json":{"schema":{"type":"object","properties":{"campaignId":{"type":"string"},"sitelinks":{"type":"array","items":{"type":"object","properties":{"assetResourceName":{"type":"string"},"campaignAssetResourceName":{"type":"string"},"text":{"type":"string"},"linkUrl":{"type":"string","format":"uri"},"description1":{"type":"string"},"description2":{"type":"string"}}}},"callouts":{"type":"array","items":{"type":"object","properties":{"assetResourceName":{"type":"string"},"campaignAssetResourceName":{"type":"string"},"calloutText":{"type":"string"}}}},"structuredSnippets":{"type":"array","items":{"type":"object","properties":{"assetResourceName":{"type":"string"},"campaignAssetResourceName":{"type":"string"},"header":{"type":"string"},"values":{"type":"array","items":{"type":"string"}}}}},"cachedAt":{"type":["string","null"],"format":"date-time","description":"Time of the cached Google read. Null when no cache was used."},"stale":{"type":"boolean","description":"True when exhausted quota required returning the last successful read."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}},"post":{"operationId":"attachCampaignAssets","summary":"Attach campaign assets","x-resource-group":"ads","tags":["Ad Campaigns"],"x-platforms":["google"],"description":"Creates and attaches sitelinks, callouts and structured snippets in one Google mutation.","security":[{"bearerAuth":[]}],"parameters":[{"name":"campaignId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Numeric Google platform id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."},"sitelinks":{"type":"array","items":{"$ref":"#/components/schemas/GoogleSitelink"},"minItems":2,"maxItems":20},"callouts":{"type":"array","items":{"type":"string","minLength":1,"maxLength":25},"minItems":1,"maxItems":20},"structuredSnippets":{"type":"array","items":{"$ref":"#/components/schemas/GoogleStructuredSnippet"},"minItems":1,"maxItems":20}},"description":"Provide at least one of sitelinks, callouts or structuredSnippets. Sitelink description1 and description2 must be supplied together."},"example":{"accountId":"64b1f0c8a1b2c3d4e5f60718","customerId":"1234567890","sitelinks":[{"text":"Pricing","linkUrl":"https://zernio.com/pricing"},{"text":"Documentation","linkUrl":"https://zernio.com/docs"}],"callouts":["Fast setup"],"structuredSnippets":[{"header":"Types","values":["Scheduling","Analytics","Messaging"]}]}}}},"responses":{"201":{"description":"Assets created and attached.","content":{"application/json":{"schema":{"type":"object","properties":{"campaignId":{"type":"string"},"sitelinkAssetResourceNames":{"type":"array","items":{"type":"string"}},"calloutAssetResourceNames":{"type":"array","items":{"type":"string"}},"structuredSnippetAssetResourceNames":{"type":"array","items":{"type":"string"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}},"put":{"operationId":"updateCampaignAssets","summary":"Update campaign assets","x-resource-group":"ads","tags":["Ad Campaigns"],"x-platforms":["google"],"description":"Edits existing Google assets in place. Send updates with assetResourceName and the fields to change. An asset is shared: changes affect every attachment using it. Omitted fields stay unchanged. The operation consumes the Google operations budget and invalidates affected cached lists.","security":[{"bearerAuth":[]}],"parameters":[{"name":"campaignId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Numeric Google platform id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","updates"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."},"updates":{"type":"array","items":{"$ref":"#/components/schemas/GoogleAssetUpdate"},"minItems":1,"maxItems":20}}},"example":{"accountId":"64b1f0c8a1b2c3d4e5f60718","customerId":"1234567890","updates":[{"assetResourceName":"customers/1234567890/assets/123","calloutAsset":{"calloutText":"Simple integration"}}]}}}},"responses":{"200":{"description":"Assets returned.","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"integer"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}},"delete":{"operationId":"removeCampaignAssets","summary":"Remove campaign assets","x-resource-group":"ads","tags":["Ad Campaigns"],"x-platforms":["google"],"description":"Removes the specified attachments only. Google assets cannot be deleted. Other attachments remain. assetResourceNames is retained for compatibility.","security":[{"bearerAuth":[]}],"parameters":[{"name":"campaignId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Numeric Google platform id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","assetResourceNames","campaignAssetResourceNames"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."},"assetResourceNames":{"type":"array","items":{"type":"string"},"minItems":1},"campaignAssetResourceNames":{"type":"array","items":{"type":"string"},"minItems":1}}},"example":{"accountId":"64b1f0c8a1b2c3d4e5f60718","customerId":"1234567890","assetResourceNames":["customers/1234567890/assets/123"],"campaignAssetResourceNames":["customers/1234567890/campaignAssets/456~123~CALLOUT"]}}}},"responses":{"200":{"description":"Assets returned.","content":{"application/json":{"schema":{"type":"object","properties":{"removed":{"type":"boolean"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}}},"/v1/ads/ad-sets/{adSetId}/assets":{"get":{"operationId":"listAdGroupAssets","summary":"List ad-group assets","x-resource-group":"ads","tags":["Ad Campaigns"],"x-platforms":["google"],"description":"Lists directly attached Google assets. Fresh reads are cached for 10 minutes; exhausted quota may return the last successful read with stale=true. Inherited assets are not included.","security":[{"bearerAuth":[]}],"parameters":[{"name":"adSetId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Numeric Google platform id."},{"name":"accountId","in":"query","required":true,"schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."}},{"name":"customerId","in":"query","schema":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."}}],"responses":{"200":{"description":"Assets returned.","content":{"application/json":{"schema":{"type":"object","properties":{"adGroupId":{"type":"string"},"sitelinks":{"type":"array","items":{"type":"object","properties":{"assetResourceName":{"type":"string"},"adGroupAssetResourceName":{"type":"string"},"text":{"type":"string"},"linkUrl":{"type":"string","format":"uri"},"description1":{"type":"string"},"description2":{"type":"string"}}}},"callouts":{"type":"array","items":{"type":"object","properties":{"assetResourceName":{"type":"string"},"adGroupAssetResourceName":{"type":"string"},"calloutText":{"type":"string"}}}},"structuredSnippets":{"type":"array","items":{"type":"object","properties":{"assetResourceName":{"type":"string"},"adGroupAssetResourceName":{"type":"string"},"header":{"type":"string"},"values":{"type":"array","items":{"type":"string"}}}}},"cachedAt":{"type":["string","null"],"format":"date-time","description":"Time of the cached Google read. Null when no cache was used."},"stale":{"type":"boolean","description":"True when exhausted quota required returning the last successful read."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}},"post":{"operationId":"attachAdGroupAssets","summary":"Attach ad-group assets","x-resource-group":"ads","tags":["Ad Campaigns"],"x-platforms":["google"],"description":"Creates and attaches sitelinks, callouts and structured snippets in one Google mutation.","security":[{"bearerAuth":[]}],"parameters":[{"name":"adSetId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Numeric Google platform id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."},"sitelinks":{"type":"array","items":{"$ref":"#/components/schemas/GoogleSitelink"},"minItems":2,"maxItems":20},"callouts":{"type":"array","items":{"type":"string","minLength":1,"maxLength":25},"minItems":1,"maxItems":20},"structuredSnippets":{"type":"array","items":{"$ref":"#/components/schemas/GoogleStructuredSnippet"},"minItems":1,"maxItems":20}},"description":"Provide at least one of sitelinks, callouts or structuredSnippets. Sitelink description1 and description2 must be supplied together."},"example":{"accountId":"64b1f0c8a1b2c3d4e5f60718","customerId":"1234567890","sitelinks":[{"text":"Pricing","linkUrl":"https://zernio.com/pricing"},{"text":"Documentation","linkUrl":"https://zernio.com/docs"}],"callouts":["Fast setup"],"structuredSnippets":[{"header":"Types","values":["Scheduling","Analytics","Messaging"]}]}}}},"responses":{"201":{"description":"Assets created and attached.","content":{"application/json":{"schema":{"type":"object","properties":{"adGroupId":{"type":"string"},"sitelinkAssetResourceNames":{"type":"array","items":{"type":"string"}},"calloutAssetResourceNames":{"type":"array","items":{"type":"string"}},"structuredSnippetAssetResourceNames":{"type":"array","items":{"type":"string"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}},"put":{"operationId":"updateAdGroupAssets","summary":"Update ad-group assets","x-resource-group":"ads","tags":["Ad Campaigns"],"x-platforms":["google"],"description":"Edits existing Google assets in place. Send updates with assetResourceName and the fields to change. An asset is shared: changes affect every attachment using it. Omitted fields stay unchanged. The operation consumes the Google operations budget and invalidates affected cached lists.","security":[{"bearerAuth":[]}],"parameters":[{"name":"adSetId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Numeric Google platform id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","updates"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."},"updates":{"type":"array","items":{"$ref":"#/components/schemas/GoogleAssetUpdate"},"minItems":1,"maxItems":20}}},"example":{"accountId":"64b1f0c8a1b2c3d4e5f60718","customerId":"1234567890","updates":[{"assetResourceName":"customers/1234567890/assets/123","calloutAsset":{"calloutText":"Simple integration"}}]}}}},"responses":{"200":{"description":"Assets returned.","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"integer"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}},"delete":{"operationId":"removeAdGroupAssets","summary":"Remove ad-group assets","x-resource-group":"ads","tags":["Ad Campaigns"],"x-platforms":["google"],"description":"Removes the specified attachments only. Google assets cannot be deleted. Other attachments remain. assetResourceNames is retained for compatibility.","security":[{"bearerAuth":[]}],"parameters":[{"name":"adSetId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Numeric Google platform id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","assetResourceNames","adGroupAssetResourceNames"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."},"assetResourceNames":{"type":"array","items":{"type":"string"},"minItems":1},"adGroupAssetResourceNames":{"type":"array","items":{"type":"string"},"minItems":1}}},"example":{"accountId":"64b1f0c8a1b2c3d4e5f60718","customerId":"1234567890","assetResourceNames":["customers/1234567890/assets/123"],"adGroupAssetResourceNames":["customers/1234567890/adGroupAssets/456~123~CALLOUT"]}}}},"responses":{"200":{"description":"Assets returned.","content":{"application/json":{"schema":{"type":"object","properties":{"removed":{"type":"boolean"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}}},"/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\ntimeline over the requested date range (summed across the campaign's ads), and optional\ndemographic breakdowns. Breakdowns are fetched live from Meta at the campaign level (one call\nper dimension, no per-ad fan-out), so an agency dashboard gets campaign-level age/gender/etc.\nwithout summing thousands of per-ad reads. `campaignId` is the platform campaign id; pass\n`platform` when a campaign id could be ambiguous across platforms. If no date range is provided,\ndefaults to the last 90 days. Date range is capped at 730 days max.\nGoogle adds searchImpressionShare, searchBudgetLostImpressionShare,\nsearchRankLostImpressionShare, searchTopImpressionShare and searchAbsoluteTopImpressionShare\nunder analytics.summary for the requested inclusive range. These ratios are queried\ntogether without daily segmentation and cached for 10 minutes. Unavailable values are\nnull. analytics.impressionShareCache reports cachedAt and stale independently of synced metrics.\n","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.\n\n**Meta**: age, gender, country, publisher_platform, device_platform, region,\nplatform_position, impression_device, video_asset, image_asset, body_asset, title_asset.\n\n**LinkedIn** (firmographics): job_title, job_function, seniority, industry,\ncompany, company_size, country, region. Rows carry the raw pivot `value`\nplus a resolved `name`. LinkedIn serves these aggregated over the whole\nrange, delays the data 12-24h, and omits segments with fewer than 3 events.\n"}],"responses":{"200":{"description":"Campaign analytics","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CampaignAnalyticsResponse"},"example":{"campaign":{"id":"123456789","platform":"google"},"analytics":{"summary":{"searchImpressionShare":0.42,"searchBudgetLostImpressionShare":0.13,"searchRankLostImpressionShare":0.45,"searchTopImpressionShare":0.31,"searchAbsoluteTopImpressionShare":null},"impressionShareCache":{"cachedAt":"2026-09-09T10:00:00Z","stale":false},"daily":[]}}}}},"202":{"description":"Historical data is incomplete and backfill remains pending.","headers":{"Retry-After":{"$ref":"#/components/headers/BackfillRetryAfter"}},"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/CampaignAnalyticsResponse"},{"type":"object","required":["backfillPending"],"properties":{"backfillPending":{"type":"boolean","description":"Always true on this response. Part of the requested range is still being backfilled; retry until the request returns 200."}}}]}}}},"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"},"429":{"description":"Google operations budget or quota exhausted without a cached impression-share result."}}}},"/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\n`/generatepreviews`. Provide exactly one creative source: `existingCreativeId` or `creativeSpec`.\nEach preview is an HTML `<iframe>` snippet embeddable directly. Unknown `formats` values\nreturn Meta's 400 verbatim.\n","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":"Platform ad account id (Meta act_<n>, Google customer id, LinkedIn account id, ...)."},"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; the message carries Meta's error"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\n`<iframe>` snippet embeddable directly. Unknown `formats` values return Meta's 400 verbatim.\n","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; the 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/{adId}/media":{"get":{"x-resource-group":"ads","operationId":"getAdMedia","tags":["Ad Creatives"],"x-platforms":["meta"],"summary":"Direct video and image URLs for an ad","description":"Returns the direct signed URLs for every video and image asset used by an ad's live\ncreative, normalised across shapes: single image/video, carousel,\nReels/Story (`object_story_spec.video_data`) and dynamic\ncreative (`asset_feed_spec`). Video items include Meta's poster thumbnail and the\nvideo's Meta id when available.\n\nReads Meta live rather than the stored creative blob because Meta's signed fbcdn\nURLs carry an `oe=<hex>` expiration (image_url ~24 h, video source ~12 d). Treat\nURLs as short-lived: re-fetch this endpoint before serving or downloading assets\ninstead of caching URLs beyond that window.","security":[{"bearerAuth":[]}],"parameters":[{"name":"adId","in":"path","required":true,"schema":{"type":"string"},"description":"Zernio ad id (24-char hex) or platform ad id."}],"responses":{"200":{"description":"Media assets","content":{"application/json":{"schema":{"type":"object","properties":{"adId":{"type":"string"},"platform":{"type":"string","description":"'facebook' or 'instagram'. Only Meta is supported for now."},"media":{"type":"array","items":{"type":"object","properties":{"type":{"type":"string","enum":["image","video"]},"url":{"type":"string","description":"Direct file URL (signed; short-lived, see description)."},"thumbnailUrl":{"type":"string","description":"Video poster URL (videos only)."},"videoId":{"type":"string","description":"Meta video id (videos only), reusable as video.id on the create endpoints."},"length":{"type":"number","description":"Video length in seconds (videos only)."},"index":{"type":"integer","description":"0-based position for carousel children or asset_feed_spec entries."}}}}}}}}},"400":{"description":"Invalid input"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Ad not found"},"422":{"description":"No active Meta connection for this ad. Reconnect the account."},"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","description":"Google Ads only. Runs Keyword Planner's generateKeywordIdeas from seed keywords, a seed URL,\nor both, returning idea rows verbatim (avgMonthlySearches, competition, competitionIndex,\ntop-of-page bid micros, monthlySearchVolumes). Counters are int64s encoded as strings; bid\nvalues are micros of the account currency. Omitting `countries` targets worldwide.\n","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; the message carries Google's error"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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":"Get historical keyword metrics","description":"Google Ads only. Runs Keyword Planner's generateKeywordHistoricalMetrics for up to 1,000\nexact keywords: historical search volume, competition and top-of-page bid ranges, plus\naverageCpcMicros when includeAverageCpc is set. Rows come back verbatim; counters are int64s\nencoded as strings, bid/CPC values are micros of the account currency.\n","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; the message carries Google's error"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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:\n\n**Meta (facebook/instagram)**: forwards caller-chosen `fields`, `breakdowns` and `filtering`\nto any Meta insights node and returns Meta's rows verbatim. `objectId` (required) selects the\nnode; `level` sets row granularity. Semantic validation is Meta's: an unknown field or invalid\nbreakdown combination returns a 400 carrying Meta's message. For long ranges or agency-scale\naccounts prefer the async variant (POST /v1/ads/insights/reports).\n\n**Google Ads (googleads)**: raw GAQL passthrough. Send any read-only GAQL SELECT via `query`\n(campaign/keyword/search-term/geo/demographic/asset/shopping resources, `change_event`, any\n`segments.*`) and rows come back verbatim (camelCase, counters as strings). Results are paged\nat a fixed 10,000 rows; follow `paging.nextPageToken` with `pageToken`. `customerId` is only\nneeded when the connection has several Google Ads accounts. Semantic validation is Google's:\nan invalid query returns a 400 carrying Google's message (note: selecting `segments.date`\nrequires a finite date filter).\n","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); the message carries the platform's error"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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 async insights report","description":"Submits an asynchronous Meta insights report. Same query surface as GET /v1/ads/insights, but\nin the JSON body; Meta processes the report server-side, which is the right choice for long\nranges or large accounts where the sync query is slow or rate-limited. Returns a `reportRunId`\nto poll via GET /v1/ads/insights/reports/{reportRunId}.\n","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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\nruns, returns `status` and `percentCompletion`. Once `status` is \"Job Completed\" the response\nalso carries a `data` page, cursor-paginated via `limit` / `after`.\n","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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\nover the requested date range, and optional demographic breakdowns (Meta and TikTok only).\nIf no date range is provided, defaults to the last 90 days. Date range is capped at 730 days max.\n","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.\n\n**Meta**: age, gender, country, publisher_platform, device_platform, region.\n\n**TikTok**: gender, age, country_code, platform, ac, language.\n\n**LinkedIn** (firmographics): job_title, job_function, seniority, industry,\ncompany, company_size, country, region. Rows carry the raw pivot `value`\nplus a resolved `name`. LinkedIn serves these aggregated over the whole\nrange, delays the data 12-24h, and omits segments with fewer than 3 events.\n"}],"responses":{"200":{"description":"Ad analytics","content":{"application/json":{"schema":{"$ref":"#/components/schemas/AdAnalyticsResponse"}}}},"202":{"description":"Historical data is incomplete and backfill remains pending.","headers":{"Retry-After":{"$ref":"#/components/headers/BackfillRetryAfter"}},"content":{"application/json":{"schema":{"allOf":[{"$ref":"#/components/schemas/AdAnalyticsResponse"},{"type":"object","required":["backfillPending"],"properties":{"backfillPending":{"type":"boolean","description":"Always true on this response. Part of the requested range is still being backfilled; retry until the request returns 200."}}}]}}}},"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.\n- Meta (facebook/instagram): the creative's `url_tags` (and template_url_spec).\n- Google (googleads): the campaign's `trackingUrlTemplate` + `finalUrlSuffix`.\n  Subject to the Google Ads API access-tier daily quota; bulk audits need Standard access.\n- LinkedIn (linkedinads): the campaign's Dynamic UTM `dynamicValueParameters` + `customValueParameters`.\nReturns 405 for platforms without a click-URL tracking surface (TikTok, X, Pinterest).\n\n**Not pixels.** Despite the shared path segment, this endpoint has nothing to do with\nmeasurement tags. For an ad account's pixels use\n`GET /v1/accounts/{accountId}/tracking-tags?adAccountId=act_...` (Meta Pixels, with `kind`\nand `ownerAdAccountId`) or `GET /v1/accounts/{accountId}/conversion-destinations`.\n","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:\n- Meta: `urlTags` (array of {key,value}). Meta creatives are immutable, so this rebuilds the\n  creative and repoints the ad. By DEFAULT we PRESERVE the existing creative verbatim\n  (re-post its object_story_spec + the new url_tags, reusing the image), so you send `urlTags`\n  ALONE, with no need to read back headline/body/CTA. `creative` (headline, body, callToAction,\n  linkUrl, imageUrl) is OPTIONAL and only needed to rebuild explicitly, or for SHARE / page-post\n  / dark / asset_feed creatives whose object_story_spec Meta strips (those return 422 asking for\n  `creative`).\n- Google: `trackingUrlTemplate` and/or `finalUrlSuffix` (full template strings; account quota applies).\n- LinkedIn: `dynamicValueParameters` and/or `customValueParameters` (campaign-level Dynamic UTM).\n","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":"The tags as they now stand, in the same shape the GET on this path returns:\n`platform` plus the fields that platform supports. Meta returns `level`,\n`urlTags` and `templateUrlSpec`; Google returns `trackingUrlTemplate` and\n`finalUrlSuffix`. A field the platform does not support is absent.\n","content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"type":"string"}},"additionalProperties":true}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."},"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)"},"502":{"description":"Meta accepted the request then failed to produce the media (upload session, chunk transfer, processing timeout, or a response with no image hash). Inspect `platformError.reason`."}}}},"/v1/ads/{adId}/comments":{"get":{"x-resource-group":"engagement","operationId":"getAdComments","tags":["Ad Accounts"],"x-platforms":["meta","tiktok"],"summary":"List comments on an ad","description":"Returns comments on an ad's underlying creative post. Useful for moderating or analyzing\nengagement on dark posts (ad creatives that never went live organically), which the\nregular GET /v1/inbox/comments/{postId} endpoint cannot serve because dark posts are\nnot in Zernio's post database.\n\nAn ad that runs on both Facebook feed and Instagram feed has two separate underlying\nposts with separate comment threads (the creative's effective_object_story_id and\neffective_instagram_media_id). Use the `placement` query param to pick one; with no\nparam the Instagram side is returned when it exists, otherwise Facebook. The\nidentifiers are read from the ad record (persisted during sync) with a Marketing-API\nfallback for ads that predate the field.\n\nFor Instagram-placed comments, the Instagram account that runs the ad must be connected\nto Zernio, because those comments are read through that account's token. If no connected\nInstagram account on the profile can read the ad's media, the call returns\nads_connection_required (the Facebook side, if any, is still readable via ?placement=facebook).\n\nTikTok uses the connected TikTok Ads advertiser token and supports both paid video\nads and Spark Ads. `since` and `until` select a date window of at most 30 days;\nthe default is the last 30 days. TikTok searches by ad group, so Zernio filters\neach page to this ad. A page can be empty while `pagination.hasMore` is true.\nReuse `pagination.cursor` with the same `limit`; the cursor retains the date window.\n`placement` is Meta-only and returns a 400 for TikTok.\nListing needs no identity or video item ID. When the ad group is stored, each\npage makes one comment-list call and no ad-detail lookup, including for external\nads that TikTok no longer returns from ad details. `meta.tiktokItemId: null`\ndoes not prevent listing. If the ad group is missing, Zernio fetches ad details;\nunavailable details return 404 ad_not_found, and no ad group returns 400 ad_not_commentable.\n\nTikTok returns replies as separate comments with `parentId`; nested reply fetching\nis not supported. `canReply` requires a first-level comment, comment-management\npermission, a video item ID and a supported TT_USER or CUSTOMIZED_USER identity.\n`canDelete` requires TikTok's own-comment deletion capability, a video item ID\nand a supported identity. Both flags are false when identity or item is unknown.\nListing uses stored and comment-specific fields without fetching identity.\nA direct reply or delete request can lazily resolve missing fields and succeed\neven after a false flag. `canHide` is true because visibility changes need only\nadvertiser and comment IDs. `canLike` is false. Use the ad comment reply, hide\nand delete operations below to moderate TikTok comments.\nOther platforms return feature_not_available.\n\nRequires the Ads add-on. Response shape matches GET /v1/inbox/comments/{postId}.\n\nThe `{adId}` path segment accepts any identifier dialect Zernio indexes for the ad:\nZernio internal `_id` (24-char hex), the numeric `platformAdId` (the value shipped in\n`comment.received` webhooks as `comment.ad.id`), or the creative's\n`effective_object_story_id` / `effective_instagram_media_id`. Caller doesn't need a\ntranslation step.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"adId","in":"path","required":true,"schema":{"type":"string"},"description":"Internal Zernio ad ID or indexed platform ad/post ID."},{"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":"since","in":"query","schema":{"type":"string","format":"date"},"description":"TikTok-only start date. Defaults to 30 days before until. Maximum window is 30 days."},{"name":"until","in":"query","schema":{"type":"string","format":"date"},"description":"TikTok-only end date. Defaults to today in UTC."},{"name":"cursor","in":"query","schema":{"type":"string"},"description":"Pagination cursor from a previous response."}],"responses":{"200":{"description":"Comments on the ad.","content":{"application/json":{"schema":{"type":"object","required":["status","comments","pagination","meta"],"properties":{"status":{"type":"string","enum":["success"]},"comments":{"type":"array","items":{"type":"object","description":"Normalized comment. Same shape as /v1/inbox/comments/{postId} responses."}},"pagination":{"type":"object","properties":{"hasMore":{"type":"boolean"},"cursor":{"type":"string"}}},"meta":{"type":"object","required":["platform","adId","accountId","lastUpdated"],"properties":{"platform":{"type":"string","enum":["facebook","instagram","tiktok"],"description":"Platform of the comments."},"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":"Platform 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."},"tiktokItemId":{"type":["string","null"],"description":"TikTok-only video item ID from stored ad fields or returned comments. Null does not prevent listing; ad details are not fetched to populate it."},"since":{"type":"string","format":"date","description":"TikTok-only resolved start date."},"until":{"type":"string","format":"date","description":"TikTok-only resolved end date."},"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":"Account ID (ads SocialAccount)."},"lastUpdated":{"type":"string","format":"date-time"}}}}},"example":{"status":"success","comments":[{"id":"7512345678901234567","message":"Can you share more details?","createdTime":"2026-09-08T10:00:00.000Z","from":{"id":"6123456789123456789","name":"reader","username":"reader","picture":"https://example.com/avatar.jpg","isOwner":false},"likeCount":4,"replyCount":0,"platform":"tiktok","url":null,"replies":[],"isHidden":false,"canReply":false,"canDelete":false,"canHide":true,"canLike":false,"isLiked":false}],"pagination":{"hasMore":false},"meta":{"platform":"tiktok","adId":"507f1f77bcf86cd799439011","platformAdId":"1790166588666881","accountId":"507f1f77bcf86cd799439012","tiktokItemId":null,"since":"2026-08-10","until":"2026-09-09","lastUpdated":"2026-09-09T12:00:00.000Z"}}}}},"400":{"description":"Invalid ad ID format, or the ad's creative format does not expose a commentable\nunderlying post (code ad_not_commentable).\n"},"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 or TikTok (code feature_not_available)."},"404":{"$ref":"#/components/responses/NotFound"},"422":{"description":"Ads account token unavailable, or (for Instagram-placed ads) no connected\nInstagram account on the profile can read the ad's media (code ads_connection_required).\n"}}}},"/v1/ads/{adId}/comments/{commentId}/reply":{"post":{"operationId":"replyToAdComment","summary":"Reply to an ad comment","tags":["Ad Accounts"],"x-resource-group":"engagement","x-platforms":["tiktok"],"description":"Reply to a first-level TikTok ad comment. Requires a TT_USER or CUSTOMIZED_USER identity with comment-management permission. Replies to replies are rejected. The response commentId identifies the new reply. This operation is not idempotent; do not blindly retry an uncertain response.\n\nUnknown identity and video item fields are resolved only when needed for this\naction, then persisted for reuse. Comment-specific fields take precedence.\nIf TikTok no longer returns the ad needed to resolve identity, 404 ad_not_found\ndirects you to check deletion or archival in TikTok Ads Manager. Listing can\nstill succeed. Unsupported or unavailable identity returns 403 feature_not_available.\nDenied access to ad details returns 403 insufficient_permissions with reconnect\nguidance and the upstream platformError.\n\nRequires Ads access. The ad is resolved within the caller's accessible profiles.\nBefore moderation, Zernio verifies that the comment belongs to this ad using\nTikTok's ad-group comment listing. The default search window is the last 30 days.\nUse since/until for older comments, with at most 30 days between the dates.\nLookups scan at most 2,000 ad-group comments; narrow the date window if exceeded.\nMeta returns 501 feature_not_available with guidance to use the existing inbox\ncomment endpoints and the account/post IDs from GET /v1/ads/{adId}/comments.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"adId","in":"path","required":true,"schema":{"type":"string"},"description":"Internal Zernio ad ID or indexed platform ad ID."},{"name":"commentId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"TikTok comment ID from the ad comment listing."},{"name":"since","in":"query","schema":{"type":"string","format":"date"},"description":"Start date of the comment lookup window. Defaults to 30 days before until."},{"name":"until","in":"query","schema":{"type":"string","format":"date"},"description":"End date of the comment lookup window. Defaults to today in UTC."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["text"],"properties":{"text":{"type":"string","minLength":1,"description":"Non-empty reply text."}}},"example":{"text":"Thanks for your question!"}}}},"responses":{"200":{"description":"Comment action completed.","content":{"application/json":{"schema":{"type":"object","required":["status","commentId"],"properties":{"status":{"type":"string","enum":["success"]},"commentId":{"type":"string","description":"ID of the created reply or moderated comment."}}},"example":{"status":"success","commentId":"7512345678901234567"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access or supported identity is unavailable (feature_not_available), or TikTok denies ad-detail access or comment-management permission (insufficient_permissions). Grant permission and reconnect the TikTok Ads account before retrying."},"404":{"description":"Ad is inaccessible or unavailable on TikTok for identity resolution (ad_not_found), or the comment was not found on this ad in the selected date window (resource_not_found)."},"422":{"description":"TikTok Ads connection is unavailable."},"501":{"description":"Moderation on this route supports TikTok. Use the inbox comment routes for Meta."},"502":{"description":"TikTok rejected the request or was unavailable. Inspect platformError for its code and message."}}}},"/v1/ads/{adId}/comments/{commentId}/hide":{"post":{"operationId":"hideAdComment","summary":"Hide or unhide an ad comment","tags":["Ad Accounts"],"x-resource-group":"engagement","x-platforms":["tiktok"],"description":"Hide or restore a TikTok ad comment. Send hidden=true to hide it or hidden=false to make it public again. Identity and video item ID are not required; no identity lookup is performed.\n\nRequires Ads access. The ad is resolved within the caller's accessible profiles.\nBefore moderation, Zernio verifies that the comment belongs to this ad using\nTikTok's ad-group comment listing. The default search window is the last 30 days.\nUse since/until for older comments, with at most 30 days between the dates.\nLookups scan at most 2,000 ad-group comments; narrow the date window if exceeded.\nMeta returns 501 feature_not_available with guidance to use the existing inbox\ncomment endpoints and the account/post IDs from GET /v1/ads/{adId}/comments.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"adId","in":"path","required":true,"schema":{"type":"string"},"description":"Internal Zernio ad ID or indexed platform ad ID."},{"name":"commentId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"TikTok comment ID from the ad comment listing."},{"name":"since","in":"query","schema":{"type":"string","format":"date"},"description":"Start date of the comment lookup window. Defaults to 30 days before until."},{"name":"until","in":"query","schema":{"type":"string","format":"date"},"description":"End date of the comment lookup window. Defaults to today in UTC."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["hidden"],"properties":{"hidden":{"type":"boolean","description":"True to hide the comment; false to restore it."}}},"example":{"hidden":true}}}},"responses":{"200":{"description":"Comment action completed.","content":{"application/json":{"schema":{"type":"object","required":["status","commentId"],"properties":{"status":{"type":"string","enum":["success"]},"commentId":{"type":"string","description":"ID of the created reply or moderated comment."},"hidden":{"type":"boolean","description":"The requested visibility state."}}},"example":{"status":"success","commentId":"7512345678901234567","hidden":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access or the required TikTok comment capability is unavailable."},"404":{"description":"Ad is inaccessible or the comment was not found on this ad in the selected date window."},"422":{"description":"TikTok Ads connection is unavailable."},"501":{"description":"Moderation on this route supports TikTok. Use the inbox comment routes for Meta."},"502":{"description":"TikTok rejected the request or was unavailable. Inspect platformError for its code and message."}}}},"/v1/ads/{adId}/comments/{commentId}":{"delete":{"operationId":"deleteAdComment","summary":"Delete an ad comment","tags":["Ad Accounts"],"x-resource-group":"engagement","x-platforms":["tiktok"],"description":"Delete your own TikTok ad comment or reply. TikTok must return can_delete=true for the comment. Other users' comments can be hidden instead.\n\nUnknown identity and video item fields are resolved only when needed for this\naction, then persisted for reuse. Comment-specific fields take precedence.\nIf TikTok no longer returns the ad needed to resolve identity, 404 ad_not_found\ndirects you to check deletion or archival in TikTok Ads Manager. Listing can\nstill succeed. Unsupported or unavailable identity returns 403 feature_not_available.\nDenied access to ad details returns 403 insufficient_permissions with reconnect\nguidance and the upstream platformError.\n\nRequires Ads access. The ad is resolved within the caller's accessible profiles.\nBefore moderation, Zernio verifies that the comment belongs to this ad using\nTikTok's ad-group comment listing. The default search window is the last 30 days.\nUse since/until for older comments, with at most 30 days between the dates.\nLookups scan at most 2,000 ad-group comments; narrow the date window if exceeded.\nMeta returns 501 feature_not_available with guidance to use the existing inbox\ncomment endpoints and the account/post IDs from GET /v1/ads/{adId}/comments.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"adId","in":"path","required":true,"schema":{"type":"string"},"description":"Internal Zernio ad ID or indexed platform ad ID."},{"name":"commentId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"TikTok comment ID from the ad comment listing."},{"name":"since","in":"query","schema":{"type":"string","format":"date"},"description":"Start date of the comment lookup window. Defaults to 30 days before until."},{"name":"until","in":"query","schema":{"type":"string","format":"date"},"description":"End date of the comment lookup window. Defaults to today in UTC."}],"responses":{"200":{"description":"Comment action completed.","content":{"application/json":{"schema":{"type":"object","required":["status","commentId"],"properties":{"status":{"type":"string","enum":["success"]},"commentId":{"type":"string","description":"ID of the created reply or moderated comment."}}},"example":{"status":"success","commentId":"7512345678901234567"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access, own-comment deletion or supported identity is unavailable (feature_not_available), or TikTok denies ad-detail access (insufficient_permissions)."},"404":{"description":"Ad is inaccessible or unavailable on TikTok for identity resolution (ad_not_found), or the comment was not found on this ad in the selected date window (resource_not_found)."},"422":{"description":"TikTok Ads connection is unavailable."},"501":{"description":"Moderation on this route supports TikTok. Use the inbox comment routes for Meta."},"502":{"description":"TikTok rejected the request or was unavailable. Inspect platformError for its code and message."}}}},"/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.\nEach BC reports its advertiser count so callers can build agency-style pickers\nwithout re-walking `/v1/ads/accounts` per BC.\n\nTikTok-only. Solo advertisers (non-agency tokens) return an empty array.\n","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":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\n(creates, edits, status flips, budget changes...) with Meta's translated event names and\nthe structured before/after in `extra_data`. Rows are returned verbatim. Meta has no\nserver-side per-object filter on this edge, so `objectId` filters the returned page\nclient-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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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 reach-frequency prediction","description":"Creates an R&F prediction. This is a QUOTE, nothing is bought and no ad entities are created.\nProvide a date range plus exactly one of `budgetAmount` (Meta predicts reach) or `reach`\n(Meta predicts the budget). The response carries the estimate and its allowed bounds\n(min/max budget and reach). Predictions expire on their own; to buy, reserve one via\nPOST /v1/ads/rf-predictions/{predictionId}/reserve and pass the RESERVED id to\nPOST /v1/ads/create with `buyingType: \"RESERVED\"`.\n\nReservation campaigns reject automatic placements. Top-level `placements` wins; when it is\nomitted, `targeting.placements` is used; when neither is set, placements default to\nFacebook feed (+ Instagram stream when a linked IG professional account resolves).\nInstagram 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":"Platform ad account id (Meta act_<n>, Google customer id, LinkedIn account id, ...)."},"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; the message carries Meta's error"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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":"Get 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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Only supported on Meta (facebook/instagram)"}}},"delete":{"x-resource-group":"ads","operationId":"cancelRfReservation","tags":["Reach and Frequency"],"x-platforms":["meta"],"summary":"Cancel reach-frequency booking","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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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 reach-frequency inventory","description":"Locks the quoted price + inventory until the returned `expiresAt` and mints a NEW\nprediction id. Pass that RESERVED id (not the original) as `rfPredictionId` on\nPOST /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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\nreturned verbatim. The default projection covers id, name, type, timing and cells with\nsplit 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","example":"id,name,type,cells{id,name,treatment_percentage}"},"description":"Comma-separated Graph field override. Supports nested {} projections and Graph field modifiers, so a nested edge can be paged explicitly: without a .limit() modifier the expansion runs at the Meta default page size and the tail is dropped silently."},{"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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Only supported on Meta (facebook/instagram)"}}}},"/v1/ads/instagram-accounts":{"get":{"operationId":"listAdsInstagramAccounts","summary":"List Instagram ad identities","description":"Discovers identities through connected_instagram_accounts, Page linkage and Page-backed identities, with a best-effort business fallback. Business permission errors do not fail discovery. The resolved object uses the same profile-scoped resolver as ad creation; null means no identity was resolved. Format-specific observed-actor fallbacks at creative creation are not predicted.","tags":["Ad Accounts"],"x-resource-group":"ads","x-platforms":["meta"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"Zernio Meta Ads or Facebook SocialAccount ID."},{"name":"adAccountId","in":"query","required":true,"schema":{"type":"string","pattern":"^act_[0-9]+$"},"description":"Meta ad account ID including the act_ prefix."}],"responses":{"200":{"description":"Instagram identities and Page linkage.","content":{"application/json":{"schema":{"type":"object","required":["accounts","pages","resolved"],"properties":{"accounts":{"type":"array","items":{"allOf":[{"$ref":"#/components/schemas/MetaInstagramIdentityRef"},{"type":"object","required":["isPageBacked","source"],"properties":{"isPageBacked":{"type":"boolean","description":"Whether this is a Page-backed Instagram identity."},"source":{"type":"string","enum":["ad_account","page_backed","business"],"description":"Discovery source; Page linkage also uses page_backed."}}}]}},"pages":{"type":"array","items":{"type":"object","required":["pageId","name"],"properties":{"pageId":{"type":"string","description":"Facebook Page ID."},"name":{"type":"string","description":"Facebook Page name."},"instagramBusinessAccount":{"$ref":"#/components/schemas/MetaInstagramIdentityRef"},"connectedInstagramAccount":{"$ref":"#/components/schemas/MetaInstagramIdentityRef"}}}},"resolved":{"type":"object","required":["pageId","igUserId","source"],"properties":{"pageId":{"type":["string","null"],"description":"Page selected by the shared ad-creation resolver."},"igUserId":{"type":["string","null"],"description":"Instagram identity selected by the shared ad-creation resolver."},"source":{"type":["string","null"],"enum":["ad_account","page_backed","business",null],"description":"Discovery source of the resolved identity; null when absent from discovery."}}}}},"example":{"accounts":[{"igUserId":"17841400000000000","username":"example","isPageBacked":false,"source":"page_backed"}],"pages":[{"pageId":"123456789","name":"Example Page","instagramBusinessAccount":{"igUserId":"17841400000000000","username":"example"}}],"resolved":{"pageId":"123456789","igUserId":"17841400000000000","source":"page_backed"}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"The account or Meta asset is not accessible."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Only supported on Meta Ads and Facebook accounts."}}}},"/v1/ads/advertisable-applications":{"get":{"operationId":"listAdvertisableApplications","summary":"List advertisable apps","description":"Lists applications available to a Meta ad account, their supported platforms and unmodified object store URLs. A listed app still needs a configured mobile platform and store URL to run install promotion.","tags":["Ad Accounts"],"x-resource-group":"ads","x-platforms":["meta"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"Zernio Meta Ads or Facebook SocialAccount ID."},{"name":"adAccountId","in":"query","required":true,"schema":{"type":"string","pattern":"^act_[0-9]+$"},"description":"Meta ad account ID including the act_ prefix."}],"responses":{"200":{"description":"Applications available for promotion.","content":{"application/json":{"schema":{"type":"object","required":["applications"],"properties":{"applications":{"type":"array","items":{"type":"object","required":["id","name","supportedPlatforms","storeUrls"],"properties":{"id":{"type":"string","description":"Meta application ID."},"name":{"type":"string","description":"Application name."},"supportedPlatforms":{"type":"array","items":{"type":"string"},"description":"Platform identifiers reported by Meta."},"storeUrls":{"type":"object","additionalProperties":{"type":"string"},"description":"Platform-keyed store URLs returned unchanged by Meta."}}}}}},"example":{"applications":[{"id":"123456789","name":"Example App","supportedPlatforms":["IOS","ANDROID"],"storeUrls":{"iphone":"https://apps.apple.com/app/id123456789","google_play":"https://play.google.com/store/apps/details?id=com.example.app"}}]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"The account or Meta asset is not accessible."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Only supported on Meta Ads and Facebook accounts."}}}},"/v1/ads/ios-fourteen-campaign-limits":{"get":{"operationId":"getIosFourteenCampaignLimits","summary":"Get iOS 14 campaign limits","description":"Reads Meta iOS 14 campaign limits for an application on an ad account. applicationId is sent as Meta app_id. This read does not establish that the application is configured for iOS promotion.","tags":["Ad Accounts"],"x-resource-group":"ads","x-platforms":["meta"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"Zernio Meta Ads or Facebook SocialAccount ID."},{"name":"adAccountId","in":"query","required":true,"schema":{"type":"string","pattern":"^act_[0-9]+$"},"description":"Meta ad account ID including the act_ prefix."},{"name":"applicationId","in":"query","required":true,"schema":{"type":"string","pattern":"^[0-9]+$"},"description":"Meta application ID from advertisable-applications."}],"responses":{"200":{"description":"Application campaign limits.","content":{"application/json":{"schema":{"type":"object","required":["limits"],"properties":{"limits":{"type":["object","null"],"properties":{"campaignGroupLimit":{"type":["number","null"],"description":"Campaign group limit reported by Meta."},"campaignLimit":{"type":["number","null"],"description":"Campaign limit reported by Meta."},"campaignGroupLimitsDetails":{"type":"array","items":{},"description":"Campaign group limit details returned by Meta."}}}}},"example":{"limits":{"campaignGroupLimit":9,"campaignLimit":5,"campaignGroupLimitsDetails":[]}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"The account or Meta asset is not accessible."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Only supported on Meta Ads and Facebook accounts."}}}},"/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`),\nrows returned verbatim (id, name, verification_status, created_time). Token-scoped, so no\n`adAccountId` is needed. For TikTok Business Centers use\n`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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\nverbatim (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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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":"List high-demand periods","description":"Scheduled budget increases (Meta's budget-scheduling API). The Graph edge lives on the\ncampaign and ad-set nodes only, so exactly one of `campaignId` / `adSetId` (platform\nids) is required. Rows returned verbatim (budget_value, budget_value_type, time window,\nrecurrence).","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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Only supported on Meta (facebook/instagram)"}}},"post":{"x-resource-group":"ads","operationId":"createHighDemandPeriod","tags":["Ad Accounts"],"x-platforms":["meta"],"summary":"Schedule a budget increase","description":"Pre-schedule a temporary budget increase (Black Friday, a launch, a sale) instead of\nediting the budget by hand on the day. Same target rule as the GET: exactly one of\n`campaignId` / `adSetId`.\n\nTwo Meta constraints worth knowing before you call it. `timeStart` / `timeEnd` must\nfall on a 15-minute boundary, and a campaign cannot mix `ABSOLUTE` and `MULTIPLIER`\nacross its schedules; the second type is rejected with \"Can't mix your budget scaling\nselection\". Window rules (must sit inside the campaign's run dates, minimum lead time,\nno overlap) are Meta's and its message is forwarded verbatim.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","budgetValue","budgetValueType","timeStart","timeEnd"],"properties":{"accountId":{"type":"string","description":"Zernio SocialAccount id used to resolve the Meta token."},"campaignId":{"type":"string","description":"Platform campaign id. Exactly one of campaignId / adSetId."},"adSetId":{"type":"string","description":"Platform ad set id. Exactly one of campaignId / adSetId."},"budgetValue":{"type":"number","description":"With ABSOLUTE, a budget in the ad account's currency in WHOLE units (50 = $50.00). With MULTIPLIER, a factor of the existing budget (2 = double it) and NOT a currency amount."},"budgetValueType":{"type":"string","enum":["ABSOLUTE","MULTIPLIER"]},"timeStart":{"type":"integer","description":"Unix seconds, on a 15-minute boundary (:00, :15, :30, :45)."},"timeEnd":{"type":"integer","description":"Unix seconds, on a 15-minute boundary and after timeStart."},"recurrenceType":{"type":"string","enum":["ONE_TIME","WEEKLY","MONTHLY"]},"currency":{"type":"string","description":"Ad account currency, for the ABSOLUTE minor-unit conversion. Ignored for MULTIPLIER."}}}}}},"responses":{"201":{"description":"Budget schedule created","content":{"application/json":{"schema":{"type":"object","properties":{"objectId":{"type":"string","description":"The campaign / ad set the schedule was attached to."},"id":{"type":"string","description":"Meta budget schedule id."}}}}}},"400":{"description":"Invalid input, or Meta rejected the schedule"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\nverbatim. The default projection covers id, name, status, object type, thumbnail,\nobject_story_spec / asset_feed_spec and url_tags; `fields` is a raw-passthrough\noverride. Any creative id here is reusable on the create endpoints via\n`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","example":"id,name,status,object_story_spec{page_id,link_data{link,message}}"},"description":"Comma-separated Graph field override. Supports nested {} projections and Graph field modifiers, so a nested edge can be paged explicitly: without a .limit() modifier the expansion runs at the Meta default page size and the tail is dropped silently."},{"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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\n`existingCreativeId`. Provide exactly one of `imageUrl` (uploaded server-side),\n`imageHash` (from POST /v1/ads/images or the library list), or `carouselCards` (2-10\nhand-built cards). The Page (and linked Instagram account, when present) is resolved\nfrom `accountId` as the story actor. `creativeFeatures` configures Advantage+\nenhancements. `promotion` is not supported and any object is rejected with 400.","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":"Platform ad account id (Meta act_<n>, Google customer id, LinkedIn account id, ...)."},"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)."},"promotion":{"$ref":"#/components/schemas/MetaPromotion","description":"Not supported. Meta validates creative_sourcing_spec.promotion_metadata_spec on the create call and then discards it, so a Promotion set through the Marketing API never reaches the creative. Any object is rejected with 400 invalid_field_value. Send null or omit the field, and set the Promotion on the ad in Ads Manager. Verified on 2026-09-11 across Graph v19.0 to v25.0 and every write path."},"creativeFeatures":{"$ref":"#/components/schemas/MetaCreativeFeatures","description":"Meta only. Applied to each new creative, including standalone and attach shapes. With creatives[], these are defaults; an item replaces the whole feature map, including an empty map. auto_promotion_tag is an Advantage+ enhancement, not the Ads Manager Promotion setting."},"multiAdvertiser":{"type":"string","enum":["OPT_IN","OPT_OUT"],"description":"Meta only. Multi-advertiser ads: whether Meta may show this ad alongside other advertisers' in one unit. Meta auto-enrols since Aug 2024, so send OPT_OUT to leave. It is a top-level creative field, NOT a `creativeFeatures` key, and Meta rejects it there."}}},"example":{"accountId":"69fc524892b3d8e85f893e73","adAccountId":"act_123456789","headline":"Save on your next order","body":"Use SAVE20 at checkout.","linkUrl":"https://example.com/shop","imageUrl":"https://example.com/ad.jpg","creativeFeatures":{"auto_promotion_tag":"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."}}},"example":{"adAccountId":"act_123456789","creativeId":"123456789012345"}}}},"400":{"description":"Invalid input, or Meta rejected the create"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"422":{"description":"No Facebook Page found to act as the story actor"},"501":{"description":"Only supported on Meta (facebook/instagram)"},"502":{"description":"Meta accepted the request then failed to produce the media (upload session, chunk transfer, processing timeout, or a response with no image hash). Inspect `platformError.reason`."}}}},"/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\nthe 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","example":"id,name,status,object_story_spec{page_id,link_data{link,message}}"},"description":"Comma-separated Graph field override. Supports nested {} projections and Graph field modifiers, so a nested edge can be paged explicitly: without a .limit() modifier the expansion runs at the Meta default page size and the tail is dropped silently."}],"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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\ncreate a new creative (POST /v1/ads/creatives) and swap it onto the ad\n(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); its platform decides where the campaign is created."},"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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\nby 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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\nset adjusts the auction bid up or down for audience segments you value differently;\nattach one to an ad set with `valueRuleSetId` on `POST /v1/ads/create` or\n`PUT /v1/ads/ad-sets/{adSetId}`.\n\nRows are returned in the same camelCase shape the `PUT` body takes, ids included, so a\nset round-trips 1:1: **the update is a full replace, not a patch**, so you GET, mutate\nand send the whole thing back.\n\nLimits: 6 rule sets per ad account, 10 rules per set, 4 criteria per rule.\n\n**Rule order is semantic.** Rules are evaluated in array order and only the FIRST\nmatching rule adjusts the bid for an overlapping audience. The order you send is the\norder that is stored and returned.\n\nEligibility: value rule sets apply only to ad sets on the `LOWEST_COST_WITHOUT_CAP`\n(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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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`).\nAttach the returned id to an ad set with `valueRuleSetId` on `POST /v1/ads/create` or\n`PUT /v1/ads/ad-sets/{adSetId}`.\n\n**Rule order is semantic**: rules are evaluated in array order and only the first\nmatching rule adjusts the bid for an overlapping audience.\n\n`adjustValue` is an unsigned magnitude in percent; the direction lives in `adjustSign`.\n`INCREASE` accepts 1-1000, `DECREASE` accepts 1-90. There is no signed field and 0 is\nout of range.\n\n`criteriaValueTypes` is positionally paired with `criteriaValues` (same length, same\norder). Every type is the literal `\"NONE\"` except on `LOCATION`, which uses\n`LOCATION_COUNTRY` / `LOCATION_REGION` / `LOCATION_CITY` / `LOCATION_COMSCORE_MARKET`\nand may mix them within one criterion. Location values are Targeting-Search keys: a\ntwo-letter country code for `LOCATION_COUNTRY`, a numeric key for the rest.\n\n`LOCATION_DMA` was replaced by `LOCATION_COMSCORE_MARKET` on 2026-06-22 and rules using\nDMAs are no longer active, so this API rejects it.\n\n`AUDIENCE_LABEL` values (e.g. `HIGH_VALUE`) are applied to a Custom Audience in Ads\nManager. There is no API to provision them, so label strings are passed through\nunvalidated and a typo produces a rule that never fires.\n\nAds Manager turns a rule set read-only (this API stays editable) when a rule uses more\nthan 2 criteria, a custom age range, or the placements `FB_MARKETPLACE`, `FB_SEARCH`,\n`FB_VIDEO` or `IG_EXPLORE`.\n\nLimits: 6 rule sets per ad account, 10 rules per set, 4 criteria per rule. The\nper-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); its platform decides where the campaign is created."},"adAccountId":{"type":"string","description":"Platform ad account id (Meta act_<n>, Google customer id, LinkedIn account id, ...)."},"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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\none of any edit: `PUT` is a full replace, so you need the ids before you can keep the\nobjects you are not changing.\n\nMeta's own read returns `GENDER` values lowercase (`\"male\"`) while writes require\n`\"MALE\"`. Values are passed through untouched, so never case-compare a stored rule\nagainst 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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\nsend becomes the rule set.\n\n- `GET /v1/ads/value-rule-sets/{valueRuleSetId}` FIRST.\n- Keep a rule or criterion by echoing its `id`.\n- Create one by including the object WITHOUT an `id`.\n- Delete one by OMITTING it from the array. There is no warning and no undo.\n\n`name` and `rules` are both required for exactly this reason: a partial body would\nsilently destroy every rule left out.\n\n**Rule order is semantic**: the array order you send is the evaluation order, and only\nthe first matching rule adjusts the bid for an overlapping audience.\n\nExisting rule sets created elsewhere may contain `LOCATION_DMA` criteria. Those went\ninert 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); its platform decides where the campaign is created."},"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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\naction edge rather than an HTTP DELETE on its side). Ad sets pointing at it are not\nmodified here; detach them first with `valueRulesApplied: false` on\n`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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Only supported on Meta (facebook/instagram)"}}}},"/v1/ads/accounts/negative-keyword-lists":{"get":{"x-resource-group":"ads","operationId":"listAdNegativeKeywordLists","tags":["Ad Accounts"],"x-platforms":["google"],"summary":"List negative keyword lists","description":"Google Ads shared negative keyword lists (shared_set type NEGATIVE_KEYWORDS). Reads are cached for 10 minutes; quota exhaustion may return the last successful result for up to 7 days with stale=true. Customer selection is limited to this connection and its account scope.","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio SocialAccount id."}},{"name":"customerId","in":"query","required":false,"schema":{"type":"string","pattern":"^\\d+$","description":"Connected Google Ads customer id, without dashes. Required when the connection has multiple customers."}},{"name":"platform","in":"query","required":false,"schema":{"type":"string","enum":["facebook","instagram","tiktok","linkedin","pinterest","google","twitter","openai"],"description":"Optional courtesy field. The resolved account or campaign determines support; other platforms return 501."}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"type":"object","properties":{"lists":{"type":"array","items":{"$ref":"#/components/schemas/AdNegativeKeywordList"}},"customerId":{"type":"string","pattern":"^\\d+$","description":"Resolved Google Ads customer id."},"cachedAt":{"type":["string","null"],"format":"date-time","description":"Last successful fetch time, or null without cache storage."},"stale":{"type":"boolean","description":"True when quota exhaustion caused the last successful cached result to be served."}}},"example":{"lists":[{"id":"1234567890","resourceName":"customers/9122445560/sharedSets/1234567890","name":"Excluded searches","memberCount":2,"referenceCount":0}],"customerId":"9122445560","cachedAt":"2026-09-09T10:00:00Z","stale":false}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access and permission to the selected account are required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"description":"Ambiguous campaign or account selection. Use a profile-scoped key. A list still attached to a campaign may also be rejected by Google. The account may also be inactive or need reconnection (code ads_connection_required). Reconnect it and read GET /v1/accounts for its current ID before retrying."},"422":{"description":"Google Ads connection is missing or unavailable."},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Available only on Google Ads."}}},"post":{"x-resource-group":"ads","operationId":"createAdNegativeKeywordList","tags":["Ad Accounts"],"x-platforms":["google"],"summary":"Create a negative keyword list","description":"Creates one Google Ads shared negative keyword list with optional initial keywords in a single atomic mutation. Daily quota is reserved for every mutate item, so large batches may return 429 before any change. This operation is not idempotent. The list is not attached to any campaign.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["accountId","name"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio SocialAccount id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Connected Google Ads customer id, without dashes. Required when the connection has multiple customers."},"platform":{"type":"string","enum":["facebook","instagram","tiktok","linkedin","pinterest","google","twitter","openai"],"description":"Optional courtesy field. The resolved account or campaign determines support; other platforms return 501."},"name":{"type":"string","minLength":1,"maxLength":255,"description":"Nonempty list name, trimmed before use."},"keywords":{"type":"array","maxItems":5000,"items":{"$ref":"#/components/schemas/KeywordEntry"},"description":"Full desired keyword set. Bare strings use broad match. Send [] to clear the list."}}},"example":{"accountId":"69ce75d483e990e1c01ccfe4","customerId":"9122445560","name":"Excluded searches","keywords":["free",{"text":"jobs","matchType":"phrase"}]}}}},"responses":{"201":{"description":"Successful response.","content":{"application/json":{"schema":{"type":"object","properties":{"id":{"type":"string","pattern":"^\\d+$","description":"New shared set id."},"resourceName":{"type":"string","description":"New shared set resource name."},"created":{"type":"integer","description":"Number of initial keyword criteria created."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Resolved Google Ads customer id."}}},"example":{"id":"1234567890","resourceName":"customers/9122445560/sharedSets/1234567890","created":2,"customerId":"9122445560"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access and permission to the selected account are required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"description":"Ambiguous campaign or account selection. Use a profile-scoped key. A list still attached to a campaign may also be rejected by Google. The account may also be inactive or need reconnection (code ads_connection_required). Reconnect it and read GET /v1/accounts for its current ID before retrying."},"422":{"description":"Google Ads connection is missing or unavailable."},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Available only on Google Ads."}}}},"/v1/ads/accounts/negative-keyword-lists/{listId}":{"get":{"x-resource-group":"ads","operationId":"getAdNegativeKeywordList","tags":["Ad Accounts"],"x-platforms":["google"],"summary":"Get a negative keyword list","description":"Google Ads shared negative keyword lists (shared_set type NEGATIVE_KEYWORDS). Reads are cached for 10 minutes; quota exhaustion may return the last successful result for up to 7 days with stale=true. Customer selection is limited to this connection and its account scope. Includes the keywords and their criterion ids.","security":[{"bearerAuth":[]}],"parameters":[{"name":"listId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$","description":"Google shared set id."}},{"name":"accountId","in":"query","required":true,"schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio SocialAccount id."}},{"name":"customerId","in":"query","required":false,"schema":{"type":"string","pattern":"^\\d+$","description":"Connected Google Ads customer id, without dashes. Required when the connection has multiple customers."}},{"name":"platform","in":"query","required":false,"schema":{"type":"string","enum":["facebook","instagram","tiktok","linkedin","pinterest","google","twitter","openai"],"description":"Optional courtesy field. The resolved account or campaign determines support; other platforms return 501."}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"type":"object","properties":{"list":{"allOf":[{"$ref":"#/components/schemas/AdNegativeKeywordList"},{"type":"object","properties":{"keywords":{"type":"array","items":{"$ref":"#/components/schemas/AdNegativeKeywordListKeyword"}}}}]},"customerId":{"type":"string","pattern":"^\\d+$","description":"Resolved Google Ads customer id."},"cachedAt":{"type":["string","null"],"format":"date-time","description":"Last successful fetch time, or null without cache storage."},"stale":{"type":"boolean","description":"True when quota exhaustion caused the last successful cached result to be served."}}},"example":{"list":{"id":"1234567890","resourceName":"customers/9122445560/sharedSets/1234567890","name":"Excluded searches","memberCount":2,"referenceCount":0,"keywords":[{"criterionId":"111","text":"free","matchType":"broad"},{"criterionId":"222","text":"jobs","matchType":"phrase"}]},"customerId":"9122445560","cachedAt":"2026-09-09T10:00:00Z","stale":false}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access and permission to the selected account are required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"description":"Ambiguous campaign or account selection. Use a profile-scoped key. A list still attached to a campaign may also be rejected by Google. The account may also be inactive or need reconnection (code ads_connection_required). Reconnect it and read GET /v1/accounts for its current ID before retrying."},"422":{"description":"Google Ads connection is missing or unavailable."},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Available only on Google Ads."}}},"put":{"x-resource-group":"ads","operationId":"updateAdNegativeKeywordList","tags":["Ad Accounts"],"x-platforms":["google"],"summary":"Rename a negative keyword list","description":"Renames a shared negative keyword list. Keywords and campaign associations are unchanged. Use the keywords endpoint to edit the desired keyword set.","security":[{"bearerAuth":[]}],"parameters":[{"name":"listId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$","description":"Google shared set id."}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["accountId","name"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio SocialAccount id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Connected Google Ads customer id, without dashes. Required when the connection has multiple customers."},"platform":{"type":"string","enum":["facebook","instagram","tiktok","linkedin","pinterest","google","twitter","openai"],"description":"Optional courtesy field. The resolved account or campaign determines support; other platforms return 501."},"name":{"type":"string","minLength":1,"maxLength":255,"description":"Nonempty list name, trimmed before use."}}},"example":{"accountId":"69ce75d483e990e1c01ccfe4","customerId":"9122445560","name":"Excluded searches renamed"}}}},"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"boolean"},"customerId":{"type":"string","pattern":"^\\d+$","description":"Resolved Google Ads customer id."}}},"example":{"updated":true,"customerId":"9122445560"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access and permission to the selected account are required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"description":"Ambiguous campaign or account selection. Use a profile-scoped key. A list still attached to a campaign may also be rejected by Google. The account may also be inactive or need reconnection (code ads_connection_required). Reconnect it and read GET /v1/accounts for its current ID before retrying."},"422":{"description":"Google Ads connection is missing or unavailable."},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Available only on Google Ads."}}},"delete":{"x-resource-group":"ads","operationId":"deleteAdNegativeKeywordList","tags":["Ad Accounts"],"x-platforms":["google"],"summary":"Delete a negative keyword list","description":"Removes the Google shared negative keyword list. Detach it from all campaigns first; an in-use list is rejected. Only NEGATIVE_KEYWORDS shared sets are supported.","security":[{"bearerAuth":[]}],"parameters":[{"name":"listId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$","description":"Google shared set id."}},{"name":"accountId","in":"query","required":true,"schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio SocialAccount id."}},{"name":"customerId","in":"query","required":false,"schema":{"type":"string","pattern":"^\\d+$","description":"Connected Google Ads customer id, without dashes. Required when the connection has multiple customers."}},{"name":"platform","in":"query","required":false,"schema":{"type":"string","enum":["facebook","instagram","tiktok","linkedin","pinterest","google","twitter","openai"],"description":"Optional courtesy field. The resolved account or campaign determines support; other platforms return 501."}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"type":"object","properties":{"removed":{"type":"boolean"},"customerId":{"type":"string","pattern":"^\\d+$","description":"Resolved Google Ads customer id."}}},"example":{"removed":true,"customerId":"9122445560"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access and permission to the selected account are required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"description":"Ambiguous campaign or account selection. Use a profile-scoped key. A list still attached to a campaign may also be rejected by Google. The account may also be inactive or need reconnection (code ads_connection_required). Reconnect it and read GET /v1/accounts for its current ID before retrying."},"422":{"description":"Google Ads connection is missing or unavailable."},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Available only on Google Ads."}}}},"/v1/ads/accounts/negative-keyword-lists/{listId}/keywords":{"put":{"x-resource-group":"ads","operationId":"replaceAdNegativeKeywordListKeywords","tags":["Ad Accounts"],"x-platforms":["google"],"summary":"Replace negative list keywords","description":"Replaces the full desired keyword set. Existing keywords are diffed by normalized text and match type; creates and removals are applied atomically in one mutation. Unchanged criteria retain their ids. Send an empty keywords array to clear the list. Changes affect every campaign using this list. Each create or removal consumes one daily operation; the entire batch must fit the remaining quota.","security":[{"bearerAuth":[]}],"parameters":[{"name":"listId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$","description":"Google shared set id."}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["accountId","keywords"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio SocialAccount id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Connected Google Ads customer id, without dashes. Required when the connection has multiple customers."},"platform":{"type":"string","enum":["facebook","instagram","tiktok","linkedin","pinterest","google","twitter","openai"],"description":"Optional courtesy field. The resolved account or campaign determines support; other platforms return 501."},"keywords":{"type":"array","maxItems":5000,"items":{"$ref":"#/components/schemas/KeywordEntry"},"description":"Full desired keyword set. Bare strings use broad match. Send [] to clear the list."}}},"example":{"accountId":"69ce75d483e990e1c01ccfe4","customerId":"9122445560","keywords":["free",{"text":"jobs","matchType":"phrase"}]}}}},"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"type":"object","properties":{"created":{"type":"integer","description":"New criteria or campaign links created."},"removed":{"type":"integer","description":"Existing criteria or campaign links removed."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Resolved Google Ads customer id."}}},"example":{"created":1,"removed":1,"customerId":"9122445560"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access and permission to the selected account are required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"description":"Ambiguous campaign or account selection. Use a profile-scoped key. A list still attached to a campaign may also be rejected by Google. The account may also be inactive or need reconnection (code ads_connection_required). Reconnect it and read GET /v1/accounts for its current ID before retrying."},"422":{"description":"Google Ads connection is missing or unavailable."},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Available only on Google Ads."}}}},"/v1/ads/campaigns/{campaignId}/negative-keyword-lists":{"get":{"x-resource-group":"ads","operationId":"listCampaignNegativeKeywordLists","tags":["Ad Campaigns"],"x-platforms":["google"],"summary":"List campaign negative lists","description":"Returns shared negative keyword lists attached to the campaign, separate from campaign-level negative keywords. Google Ads shared negative keyword lists (shared_set type NEGATIVE_KEYWORDS). Reads are cached for 10 minutes; quota exhaustion may return the last successful result for up to 7 days with stale=true. Customer selection is limited to this connection and its account scope.","security":[{"bearerAuth":[]}],"parameters":[{"name":"campaignId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$","description":"Google campaign id."}},{"name":"platform","in":"query","schema":{"type":"string","enum":["facebook","instagram","tiktok","linkedin","pinterest","google","twitter","openai"],"description":"Optional courtesy field. The resolved account or campaign determines support; other platforms return 501."}}],"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"type":"object","properties":{"lists":{"type":"array","items":{"$ref":"#/components/schemas/AdNegativeKeywordList"}},"customerId":{"type":"string","pattern":"^\\d+$","description":"Resolved Google Ads customer id."},"cachedAt":{"type":["string","null"],"format":"date-time","description":"Last successful fetch time, or null without cache storage."},"stale":{"type":"boolean","description":"True when quota exhaustion caused the last successful cached result to be served."}}},"example":{"lists":[{"id":"1234567890","resourceName":"customers/9122445560/sharedSets/1234567890","name":"Excluded searches","memberCount":2,"referenceCount":1}],"customerId":"9122445560","cachedAt":"2026-09-09T10:00:00Z","stale":false}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access and permission to the selected account are required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"description":"Ambiguous campaign or account selection. Use a profile-scoped key. A list still attached to a campaign may also be rejected by Google. The account may also be inactive or need reconnection (code ads_connection_required). Reconnect it and read GET /v1/accounts for its current ID before retrying."},"422":{"description":"Google Ads connection is missing or unavailable."},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Available only on Google Ads."}}},"put":{"x-resource-group":"ads","operationId":"replaceCampaignNegativeKeywordLists","tags":["Ad Campaigns"],"x-platforms":["google"],"summary":"Replace campaign negative lists","description":"Sets the full desired set of shared negative keyword list associations on this campaign. Send listIds=[] to detach all negative keyword lists. Only campaign_shared_set links are changed; the lists and their keywords are preserved. Every list must belong to the campaign customer and have type NEGATIVE_KEYWORDS.","security":[{"bearerAuth":[]}],"parameters":[{"name":"campaignId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$","description":"Google campaign id."}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["listIds"],"properties":{"platform":{"type":"string","enum":["facebook","instagram","tiktok","linkedin","pinterest","google","twitter","openai"],"description":"Optional courtesy field. The resolved account or campaign determines support; other platforms return 501."},"listIds":{"type":"array","maxItems":20,"items":{"type":"string","pattern":"^\\d+$","description":"Shared negative keyword list id."}}}},"example":{"platform":"google","listIds":["1234567890"]}}}},"responses":{"200":{"description":"Successful response.","content":{"application/json":{"schema":{"type":"object","properties":{"created":{"type":"integer","description":"New criteria or campaign links created."},"removed":{"type":"integer","description":"Existing criteria or campaign links removed."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Resolved Google Ads customer id."}}},"example":{"created":1,"removed":0,"customerId":"9122445560"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access and permission to the selected account are required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"description":"Ambiguous campaign or account selection. Use a profile-scoped key. A list still attached to a campaign may also be rejected by Google. The account may also be inactive or need reconnection (code ads_connection_required). Reconnect it and read GET /v1/accounts for its current ID before retrying."},"422":{"description":"Google Ads connection is missing or unavailable."},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Available only on Google Ads."}}}},"/v1/ads/accounts/callouts":{"get":{"operationId":"listAccountCallouts","summary":"List account callouts","x-resource-group":"ads","tags":["Ad Accounts"],"x-platforms":["google"],"description":"Lists directly attached Google assets. Fresh reads are cached for 10 minutes; exhausted quota may return the last successful read with stale=true. Inherited assets are not included. Preserves Google RMF C.75 account-level callouts.","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."}},{"name":"customerId","in":"query","schema":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."}}],"responses":{"200":{"description":"Assets returned.","content":{"application/json":{"schema":{"type":"object","properties":{"customerId":{"type":"string"},"callouts":{"type":"array","items":{"type":"object","properties":{"assetId":{"type":"string"},"status":{"type":"string"},"text":{"type":"string"}}}},"cachedAt":{"type":["string","null"],"format":"date-time","description":"Time of the cached Google read. Null when no cache was used."},"stale":{"type":"boolean","description":"True when exhausted quota required returning the last successful read."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}},"post":{"operationId":"addAccountCallouts","summary":"Add account callouts","x-resource-group":"ads","tags":["Ad Accounts"],"x-platforms":["google"],"description":"Creates assets and customer_asset links for this Google customer. Links apply at account level.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","callouts"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."},"callouts":{"type":"array","items":{"type":"string","minLength":1,"maxLength":25},"minItems":1,"maxItems":20}}},"example":{"accountId":"64b1f0c8a1b2c3d4e5f60718","customerId":"1234567890","callouts":["Fast setup"]}}}},"responses":{"201":{"description":"Assets created and attached.","content":{"application/json":{"schema":{"type":"object","properties":{"customerId":{"type":"string"},"callouts":{"type":"array","items":{"type":"object","properties":{"assetId":{"type":"string"},"text":{"type":"string"}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}},"put":{"operationId":"updateAccountCallouts","summary":"Update account callouts","x-resource-group":"ads","tags":["Ad Accounts"],"x-platforms":["google"],"description":"Edits existing Google assets in place. Send updates with assetResourceName and the fields to change. An asset is shared: changes affect every attachment using it. Omitted fields stay unchanged. The operation consumes the Google operations budget and invalidates affected cached lists.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","updates"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."},"updates":{"type":"array","items":{"type":"object","required":["assetResourceName"],"properties":{"assetResourceName":{"type":"string","pattern":"^customers/\\d+/assets/\\d+$","description":"Asset resource name returned by a list operation. Must belong to the selected customer."},"calloutAsset":{"type":"object","required":["calloutText"],"properties":{"calloutText":{"type":"string","minLength":1,"maxLength":25}}}},"description":"Provide at least one field belonging to this asset type."},"minItems":1,"maxItems":20}}},"example":{"accountId":"64b1f0c8a1b2c3d4e5f60718","customerId":"1234567890","updates":[{"assetResourceName":"customers/1234567890/assets/123","calloutAsset":{"calloutText":"Simple integration"}}]}}}},"responses":{"200":{"description":"Assets returned.","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"integer"},"customerId":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}},"delete":{"operationId":"removeAccountCallout","summary":"Remove account callout","x-resource-group":"ads","tags":["Ad Accounts"],"x-platforms":["google"],"description":"Removes the customer_asset attachment only. The underlying shared asset and its campaign or ad-group attachments remain.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","assetId"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."},"assetId":{"type":"string","pattern":"^\\d+$"}}},"example":{"accountId":"64b1f0c8a1b2c3d4e5f60718","customerId":"1234567890","assetId":"123"}}}},"responses":{"200":{"description":"Assets returned.","content":{"application/json":{"schema":{"type":"object","properties":{"removed":{"type":"boolean"},"customerId":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}}},"/v1/ads/accounts/sitelinks":{"get":{"operationId":"listAccountSitelinks","summary":"List account sitelinks","x-resource-group":"ads","tags":["Ad Accounts"],"x-platforms":["google"],"description":"Lists directly attached Google assets. Fresh reads are cached for 10 minutes; exhausted quota may return the last successful read with stale=true. Inherited assets are not included.","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."}},{"name":"customerId","in":"query","schema":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."}}],"responses":{"200":{"description":"Assets returned.","content":{"application/json":{"schema":{"type":"object","properties":{"customerId":{"type":"string"},"sitelinks":{"type":"array","items":{"type":"object","properties":{"assetId":{"type":"string"},"status":{"type":"string"},"assetResourceName":{"type":"string"},"customerAssetResourceName":{"type":"string"},"text":{"type":"string"},"linkUrl":{"type":"string","format":"uri"},"description1":{"type":"string"},"description2":{"type":"string"}}}},"cachedAt":{"type":["string","null"],"format":"date-time","description":"Time of the cached Google read. Null when no cache was used."},"stale":{"type":"boolean","description":"True when exhausted quota required returning the last successful read."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}},"post":{"operationId":"addAccountSitelinks","summary":"Add account sitelinks","x-resource-group":"ads","tags":["Ad Accounts"],"x-platforms":["google"],"description":"Creates assets and customer_asset links for this Google customer. Links apply at account level.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","sitelinks"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."},"sitelinks":{"type":"array","items":{"$ref":"#/components/schemas/GoogleSitelink"},"minItems":1,"maxItems":20}}},"example":{"accountId":"64b1f0c8a1b2c3d4e5f60718","customerId":"1234567890","sitelinks":[{"text":"Pricing","linkUrl":"https://zernio.com/pricing"}]}}}},"responses":{"201":{"description":"Assets created and attached.","content":{"application/json":{"schema":{"type":"object","properties":{"customerId":{"type":"string"},"sitelinks":{"type":"array","items":{"type":"object","properties":{"assetId":{"type":"string"},"text":{"type":"string","minLength":1,"maxLength":25},"linkUrl":{"type":"string","format":"uri"},"description1":{"type":"string","minLength":1,"maxLength":35},"description2":{"type":"string","minLength":1,"maxLength":35}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}},"put":{"operationId":"updateAccountSitelinks","summary":"Update account sitelinks","x-resource-group":"ads","tags":["Ad Accounts"],"x-platforms":["google"],"description":"Edits existing Google assets in place. Send updates with assetResourceName and the fields to change. An asset is shared: changes affect every attachment using it. Omitted fields stay unchanged. The operation consumes the Google operations budget and invalidates affected cached lists.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","updates"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."},"updates":{"type":"array","items":{"type":"object","required":["assetResourceName"],"properties":{"assetResourceName":{"type":"string","pattern":"^customers/\\d+/assets/\\d+$","description":"Asset resource name returned by a list operation. Must belong to the selected customer."},"sitelinkAsset":{"type":"object","properties":{"linkText":{"type":"string","minLength":1,"maxLength":25},"description1":{"type":"string","maxLength":35},"description2":{"type":"string","maxLength":35},"linkUrl":{"type":"string","format":"uri","description":"Alias for finalUrls with one URL. Do not supply both."}},"minProperties":1},"finalUrls":{"type":"array","items":{"type":"string","format":"uri"},"minItems":1}},"description":"Provide at least one field belonging to this asset type."},"minItems":1,"maxItems":20}}},"example":{"accountId":"64b1f0c8a1b2c3d4e5f60718","customerId":"1234567890","updates":[{"assetResourceName":"customers/1234567890/assets/123","sitelinkAsset":{"linkText":"Explore pricing"},"finalUrls":["https://zernio.com/pricing"]}]}}}},"responses":{"200":{"description":"Assets returned.","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"integer"},"customerId":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}},"delete":{"operationId":"removeAccountSitelink","summary":"Remove account sitelink","x-resource-group":"ads","tags":["Ad Accounts"],"x-platforms":["google"],"description":"Removes the customer_asset attachment only. The underlying shared asset and its campaign or ad-group attachments remain.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","assetId"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."},"assetId":{"type":"string","pattern":"^\\d+$"}}},"example":{"accountId":"64b1f0c8a1b2c3d4e5f60718","customerId":"1234567890","assetId":"123"}}}},"responses":{"200":{"description":"Assets returned.","content":{"application/json":{"schema":{"type":"object","properties":{"removed":{"type":"boolean"},"customerId":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}}},"/v1/ads/accounts/structured-snippets":{"get":{"operationId":"listAccountStructuredSnippets","summary":"List account snippets","x-resource-group":"ads","tags":["Ad Accounts"],"x-platforms":["google"],"description":"Lists directly attached Google assets. Fresh reads are cached for 10 minutes; exhausted quota may return the last successful read with stale=true. Inherited assets are not included.","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."}},{"name":"customerId","in":"query","schema":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."}}],"responses":{"200":{"description":"Assets returned.","content":{"application/json":{"schema":{"type":"object","properties":{"customerId":{"type":"string"},"structuredSnippets":{"type":"array","items":{"type":"object","properties":{"assetId":{"type":"string"},"status":{"type":"string"},"assetResourceName":{"type":"string"},"customerAssetResourceName":{"type":"string"},"header":{"type":"string"},"values":{"type":"array","items":{"type":"string"}}}}},"cachedAt":{"type":["string","null"],"format":"date-time","description":"Time of the cached Google read. Null when no cache was used."},"stale":{"type":"boolean","description":"True when exhausted quota required returning the last successful read."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}},"post":{"operationId":"addAccountStructuredSnippets","summary":"Add account snippets","x-resource-group":"ads","tags":["Ad Accounts"],"x-platforms":["google"],"description":"Creates assets and customer_asset links for this Google customer. Links apply at account level.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","structuredSnippets"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."},"structuredSnippets":{"type":"array","items":{"$ref":"#/components/schemas/GoogleStructuredSnippet"},"minItems":1,"maxItems":20}}},"example":{"accountId":"64b1f0c8a1b2c3d4e5f60718","customerId":"1234567890","structuredSnippets":[{"header":"Types","values":["Scheduling","Analytics","Messaging"]}]}}}},"responses":{"201":{"description":"Assets created and attached.","content":{"application/json":{"schema":{"type":"object","properties":{"customerId":{"type":"string"},"structuredSnippets":{"type":"array","items":{"type":"object","properties":{"assetId":{"type":"string"},"header":{"type":"string","enum":["Amenities","Brands","Courses","Degree programs","Destinations","Featured hotels","Insurance coverage","Models","Neighborhoods","Service catalog","Shows","Styles","Types"]},"values":{"type":"array","minItems":3,"maxItems":10,"items":{"type":"string","minLength":1,"maxLength":25}}}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}},"put":{"operationId":"updateAccountStructuredSnippets","summary":"Update account snippets","x-resource-group":"ads","tags":["Ad Accounts"],"x-platforms":["google"],"description":"Edits existing Google assets in place. Send updates with assetResourceName and the fields to change. An asset is shared: changes affect every attachment using it. Omitted fields stay unchanged. The operation consumes the Google operations budget and invalidates affected cached lists.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","updates"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."},"updates":{"type":"array","items":{"type":"object","required":["assetResourceName"],"properties":{"assetResourceName":{"type":"string","pattern":"^customers/\\d+/assets/\\d+$","description":"Asset resource name returned by a list operation. Must belong to the selected customer."},"structuredSnippetAsset":{"$ref":"#/components/schemas/GoogleStructuredSnippet"}},"description":"Provide at least one field belonging to this asset type."},"minItems":1,"maxItems":20}}},"example":{"accountId":"64b1f0c8a1b2c3d4e5f60718","customerId":"1234567890","updates":[{"assetResourceName":"customers/1234567890/assets/123","structuredSnippetAsset":{"header":"Types","values":["Scheduling","Reporting","Messaging"]}}]}}}},"responses":{"200":{"description":"Assets returned.","content":{"application/json":{"schema":{"type":"object","properties":{"updated":{"type":"integer"},"customerId":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}},"delete":{"operationId":"removeAccountStructuredSnippet","summary":"Remove account snippet","x-resource-group":"ads","tags":["Ad Accounts"],"x-platforms":["google"],"description":"Removes the customer_asset attachment only. The underlying shared asset and its campaign or ad-group attachments remain.","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","assetId"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio Google Ads connection id."},"customerId":{"type":"string","pattern":"^\\d+$","description":"Google customer id without dashes. Required when the connection has multiple customers."},"assetId":{"type":"string","pattern":"^\\d+$"}}},"example":{"accountId":"64b1f0c8a1b2c3d4e5f60718","customerId":"1234567890","assetId":"123"}}}},"responses":{"200":{"description":"Assets returned.","content":{"application/json":{"schema":{"type":"object","properties":{"removed":{"type":"boolean"},"customerId":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access is required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"429":{"description":"Google Ads operations budget or platform quota exhausted."},"501":{"description":"Only supported on Google Ads."}}}},"/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\n`spendCap` (null = no cap) and the `fundingSource`. Money values are converted from\nMeta'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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Only supported on Meta (facebook/instagram)"}}}},"/v1/ads/accounts":{"post":{"operationId":"createAdAccount","summary":"Create Meta ad account","tags":["Ad Accounts"],"x-resource-group":"ads","x-platforms":["meta"],"description":"Creates a durable Meta ad account in the end user's own business portfolio using\ntheir connected Meta Ads token. Requires an active metaads accountId, Ads access,\nbusiness_management permission and business admin access. Discover portfolios with\nGET /v1/ads/businesses. System-user tokens may return an empty businesses list;\nsupply the known business ID in that case.\n\nThe self-serve account starts without a payment method. The user must add a payment\nmethod in Ads Manager before ads can deliver. Zernio cannot add payment methods.\nMeta may require business verification and limits how many accounts a business can\ncreate. Closing an account does not guarantee more capacity. An ad account cannot\ntruly be deleted, even after closing it and removing it from a business.\n\ntimezoneId is Meta's numeric ID, not an IANA timezone name. Select it from\nhttps://developers.facebook.com/docs/marketing-api/reference/ad-account/timezone-ids/.\nFor example, 1 is America/Los_Angeles. Meta validates supported currencies and IDs.\nendAdvertiser, mediaAgency and partner default to NONE for the self-serve flow.\n\nThe new account is added atomically to an existing scoped ad-account allowlist.\nUnrestricted connections stay unrestricted. Reconnecting the same Meta identity\npreserves this scope unless a caller explicitly replaces it. Discovery is nudged\nimmediately. Use the returned adAccountId with the existing ads endpoints.\n\nThis operation is not idempotent and Zernio never automatically retries it.\nUnknown body fields are rejected. No validateOnly or dry-run option is supported.\nAfter a timeout or a 502 with details.creationStatus=unknown, check the business\nin Ads Manager before attempting another creation. A 201 with connectionUpdated=false\nmeans the account exists but needs reconnecting with adAccountIds containing the returned ID and the previous\nscoped IDs via GET /v1/connect/facebook/ads. Do not repeat the create call.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","additionalProperties":false,"required":["accountId","businessId","name","currency","timezoneId"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$","description":"Zernio metaads SocialAccount ID."},"businessId":{"type":"string","pattern":"^[1-9][0-9]*$","description":"Business portfolio that will own the account."},"name":{"type":"string","minLength":1,"maxLength":255,"description":"Ad account name. Whitespace is trimmed."},"currency":{"type":"string","pattern":"^[A-Z]{3}$","description":"Uppercase ISO 4217 currency supported by Meta."},"timezoneId":{"type":"integer","minimum":0,"maximum":4294967295,"description":"Numeric Meta timezone ID from the linked timezone list. For example 1 is America/Los_Angeles."},"endAdvertiser":{"type":"string","pattern":"^(NONE|[1-9][0-9]*)$","default":"NONE","description":"End advertiser business or page ID. NONE uses the owning business."},"mediaAgency":{"type":"string","pattern":"^(NONE|[1-9][0-9]*)$","default":"NONE","description":"Media agency business or page ID. NONE for self-serve customers."},"partner":{"type":"string","pattern":"^(NONE|[1-9][0-9]*)$","default":"NONE","description":"Partner business or page ID. NONE for self-serve customers."},"invoice":{"type":"boolean","description":"Request Meta invoicing. Eligibility is determined by Meta."},"invoiceGroupId":{"type":"string","pattern":"^[1-9][0-9]*$","description":"Existing Meta invoice group ID."},"invoicingEmails":{"type":"array","minItems":1,"maxItems":100,"items":{"type":"string","format":"email"},"description":"Addresses for Meta invoices."},"io":{"type":"boolean","description":"Meta insertion-order invoicing option."},"poNumber":{"type":"string","minLength":1,"maxLength":255,"description":"Purchase order number."},"fundingId":{"type":"string","pattern":"^[1-9][0-9]*$","description":"Existing Meta funding reference. Does not add a payment method."},"adAccountCreatedFromBmFlag":{"type":"boolean","description":"Meta Business Manager creation flag."}}},"example":{"accountId":"69fc524892b3d8e85f893e73","businessId":"123456789012345","name":"Customer advertising","currency":"EUR","timezoneId":1}}}},"responses":{"201":{"description":"Ad account created. Check connectionUpdated and payment instructions.","content":{"application/json":{"schema":{"type":"object","required":["adAccountId","businessId","connectionUpdated","paymentMethodRequired","adsManagerUrl","nextSteps","warnings"],"properties":{"adAccountId":{"type":"string","pattern":"^act_[0-9]+$","description":"New Meta ad account ID for subsequent ads calls."},"businessId":{"type":"string","description":"Owning business portfolio ID."},"connectionUpdated":{"type":"boolean","description":"Whether the connection scope and discovery schedule were updated."},"paymentMethodRequired":{"type":"boolean","description":"Always true as a delivery prerequisite. This is not a live funding-source check. Confirm payment or invoicing in Ads Manager."},"adsManagerUrl":{"type":"string","format":"uri","description":"Open the created account in Ads Manager."},"nextSteps":{"type":"string","description":"Payment setup instructions for the user."},"warnings":{"type":"array","items":{"type":"string"},"description":"Recovery instructions if the account could not be attached to the connection."}}},"example":{"adAccountId":"act_123456789012345","businessId":"123456789012345","connectionUpdated":true,"paymentMethodRequired":true,"adsManagerUrl":"https://adsmanager.facebook.com/adsmanager/manage/accounts?act=123456789012345","nextSteps":"The user must configure payment in Meta Ads Manager before ads can deliver. Add a payment method there, or confirm an existing funding or invoicing arrangement. Zernio cannot add payment methods through the API.","warnings":[]}}}},"400":{"description":"Invalid input or Meta rejection. details.reason identifies creation_limit, business_verification_required, unsupported_currency, unsupported_timezone or business_unavailable when recognized.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"This business has reached its Meta ad account creation limit. Use an existing ad account or ask Meta Business Support about increasing the limit. Closing accounts does not guarantee additional capacity.","code":"invalid_field_value","type":"invalid_request_error","param":"businessId","platform":"meta","details":{"reason":"creation_limit"}}}}},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access denied or Meta permission missing. details.reason may be business_management_required, business_admin_required or business_access_required.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"}}}},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"502":{"description":"Creation outcome unknown. Check Ads Manager before repeating this non-idempotent request.","content":{"application/json":{"schema":{"$ref":"#/components/schemas/ErrorResponse"},"example":{"error":"Meta did not confirm whether the ad account was created. Do not repeat this request until you check the business portfolio in Ads Manager.","code":"platform_api_error","type":"platform_error","platform":"meta","details":{"creationStatus":"unknown"}}}}}}},"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 account (e.g. Meta ad\naccounts, TikTok advertiser IDs, Google Ads customer IDs).\nMeta business-login accounts use their own system-user token. Fresh Meta discovery\nincludes businessId and businessName from the owning Business Manager when available;\ncached entries gain these fields after the next discovery refresh.\n\nFor TikTok agencies: enumerates every advertiser under every Business Center the token\ncan read (paginated server-side), then chunks the lookup against TikTok's\n`/advertiser/info/` endpoint (which has a per-call cap of ≤100 IDs). Solo advertisers\nwithout a BC fall back to the OAuth-time `advertiser_ids` list. Cached for 1h on the\nSocialAccount; lazy-refreshed on first call after expiry.\n\nFor Google Ads: responds `429` when Google's API quota is temporarily exhausted\n(instead of an empty list). Retry after a delay.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"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"},"businessId":{"type":"string","description":"Meta only. Owning Business Manager ID when available on the grant."},"businessName":{"type":"string","description":"Owning business name when supplied by the platform."},"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\ndeclared because the type differs per platform: Meta sends an integer,\nGoogle, TikTok and Pinterest send a string. Absent on LinkedIn (reports\n`status`) and on X (reports `approvalStatus`).\n\nIf all you need is whether the account can run ads right now, read\n`selectable` and skip this field. Read this one when you need to tell\nthe states apart, because they call for different responses:\n\n- `1` ACTIVE. Running normally.\n- `2` DISABLED. Disabled by Meta. Read `disableReason` to tell a policy\n  action apart from a billing one; they need very different follow-ups.\n- `3` UNSETTLED. There is an unpaid balance, but the account still runs\n  ads. Not a ban.\n- `7` PENDING_RISK_REVIEW. Meta is reviewing the account. Wait for the\n  outcome.\n- `8` PENDING_SETTLEMENT. Meta blocks new ads until an outstanding\n  balance clears. Settle it and the account runs again.\n- `9` IN_GRACE_PERIOD. Still running, on a deadline.\n- `100` PENDING_CLOSURE. Scheduled to close.\n- `101` CLOSED. Terminal.\n"},"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."}}}},"cachedAt":{"type":["string","null"],"format":"date-time","description":"Google only. When this list was fetched from Google. Null when it was never served from cache, or on other platforms."},"stale":{"type":"boolean","description":"Google only. True when Google's daily API quota was exhausted and this is the last successful fetch, not a live read. Absent on other platforms."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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).\nSet them once and every EU-targeted call to `/v1/ads/create`, `/v1/ads/boost` and\n`/v1/ads/ctwa` on that ad account can omit `dsaBeneficiary`/`dsaPayor`: Meta applies\nthe defaults automatically.\n\nThe values are written to the ad account on Meta, the same setting Ads Manager edits.\nNothing is stored in Zernio, and defaults already set in Ads Manager work identically.\nZernio never guesses these values for you. Beneficiary and payor are legal disclosures\nshown to EU users, so you must provide the entity names explicitly. Use\n`GET /v1/ads/dsa-recommendations` to offer suggestions in your UI.\n\nIf `defaultDsaPayor` is omitted, the beneficiary is also set as the payor, which\ncovers the common case where the same entity benefits from and pays for the ads.\nRead the current values back with `GET /v1/ads/dsa-defaults`.\n\nCurrently supported for Meta accounts only; other platforms return 400.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","adAccountId","defaultDsaBeneficiary"],"properties":{"accountId":{"type":"string","description":"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":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}}},"/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,\nwhether they were set via `PATCH /v1/ads/accounts` or in Meta Ads Manager. Fields\nare omitted when no default is configured. Meta accounts only.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"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":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}}},"/v1/ads/dsa-recommendations":{"get":{"x-resource-group":"ads","operationId":"getDsaRecommendations","tags":["Ad Accounts"],"x-platforms":["meta"],"summary":"Get DSA recommendations","description":"Returns Meta's suggested beneficiary/payor names for an ad account, derived by Meta\nfrom the account's recent activity. Useful for prefilling `dsaBeneficiary`/`dsaPayor`\ninputs, or the defaults sent to `PATCH /v1/ads/accounts`, in your own UI.\n\nMeta returns a single flat list. Entries are not labeled as beneficiary or payor,\nand since these are legal disclosures Zernio never applies them automatically: let\nyour user pick the right entity. The list may be empty for accounts with little\nactivity. Meta accounts only.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"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":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}}},"/v1/ads/boost":{"post":{"x-resource-group":"ads","operationId":"boostPost","tags":["Ad Campaigns"],"x-platforms":["meta","google","tiktok","linkedin","pinterest","x"],"summary":"Boost post as ad","description":"Creates a paid ad from an existing published post, keeping the post's\nengagement. By default it provisions the whole hierarchy (campaign, ad\nset, ad).\n\n**Attach shape (Meta).** Send `adSetId` to put the ad under an EXISTING\nad set instead, so that ad set keeps its learning phase. It then owns\n`budget`, `schedule` and `targeting`, and sending any of those alongside\n`adSetId` is a 400 rather than a silent drop. `budget` is required only\nwithout `adSetId`.\n\n`instagramAccountId`, `destinationType`, `whatsappPhoneNumber` and `adSetId`\nare Meta-only and return 400 on other platforms.\n\n**Messaging boosts (Meta).** Use `goal: engagement` with\n`callToAction: WHATSAPP_MESSAGE`, `MESSAGE_PAGE`, or `INSTAGRAM_MESSAGE`.\nThe CTA implies WHATSAPP, MESSENGER, or INSTAGRAM_DIRECT respectively;\n`destinationType` alone does not select a messaging CTA. Omit `linkUrl`\nonly for messaging CTAs. Plain link CTAs keep their goal and link behavior\nwhen combined with an independent `destinationType`.\nThe campaign uses OUTCOME_ENGAGEMENT and the ad set uses CONVERSATIONS\nwith the promoted Page. Optional `whatsappPhoneNumber` selects a number\nalready paired with that Page. Conflicting CTA/destination, instant form,\ngoal, or optimizationGoal inputs return 400. Attach requires the target\nad set destination to match. Existing post references preserve social proof;\nan Instagram reel rejected by Meta is not re-uploaded as a new post for\na messaging boost.\n\n**Retries.** Boosts are NOT idempotent and can take minutes when Meta requires re-hosting an\nInstagram video, so do not retry on client timeout. Send an\nIdempotency-Key header to make retries safe: same key and body replays\nthe original 201, and distinct keys always create distinct ads.\nWithout the header, an identical request is treated as a retry: while\none is in flight it returns 409, and within 10 minutes of a completed\nboost it returns the already-created ad instead of creating another.\nTo intentionally duplicate an ad, send distinct Idempotency-Keys (or\nvary the body, e.g. the name).\n","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/IdempotencyKeyHeader"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","adAccountId","name","goal"],"properties":{"creativeFeatures":{"$ref":"#/components/schemas/MetaCreativeFeatures"},"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":"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. 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","INSTAGRAM_DIRECT"],"description":"Meta only. Ad-set destination_type: where the click LANDS, as opposed to instagramAccountId which is who the ad runs as. Independent of plain link CTAs and their goal. A messaging callToAction selects its destination automatically; an explicit destinationType must then match. Lead ads use ON_AD."},"whatsappPhoneNumber":{"type":"string","pattern":"^\\+[1-9]\\d{6,14}$","description":"Meta WhatsApp only. E.164 number already paired with the Page. Omit to use the default pairing. Requires WHATSAPP_MESSAGE callToAction. Stored as creative.whatsappPhoneNumber on the ad."},"currency":{"type":"string","minLength":3,"maxLength":3,"example":"USD","description":"ISO 4217 currency code matching the ad account's currency. Meta only. Optional: Zernio resolves it from the ad account when omitted. The value selects the minor-unit exponent Zernio converts budget/bid amounts by before calling Meta (most currencies are cents; zero-decimal currencies like JPY/KRW are sent as-is)."},"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.\nGeo keys (`regions`/`cities`/`zips`/`metros`) resolve via\nGET /v1/ads/targeting/search?dimension=geo. City radius and lat/lng\n`customLocations` are Meta-only and preserve the boosted post's\nsocial proof (the ad references the existing post).\n","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.\n`{ \"geo_locations\": { \"cities\": [{ \"key\": \"...\", \"radius\": 15, \"distance_unit\": \"kilometer\" }] } }`).\nSent alone it is forwarded unchanged. Use for advanced fields the structured\nobject does not expose (flexible_spec, excluded audiences, business places,\nuser_os, wireless_carrier).\n\nCan be combined with `targeting`: rawTargeting is the BASE layer and the\nbuilt camelCase spec is merged on top, key by key (camelCase wins on\ncollision). The merge goes one level deep inside `geo_locations` and\n`excluded_geo_locations` (built sub-keys win; raw-only sub-keys such as\n`location_types` survive). Array values (`flexible_spec`, ...) are replaced\nas a whole key, never element-merged.\n\nWhen `rawTargeting` is present the `advantage_audience: 0` default that\nZernio normally applies is no longer emitted, so it cannot clobber a\n`targeting_automation` sent in the raw spec. Meta requires\n`targeting_automation` on ad set creation, so include it in the raw spec,\nor send `targeting.advantage_audience` (0 or 1), which is merged over raw\nas `targeting_automation`.\n"},"bidStrategy":{"allOf":[{"$ref":"#/components/schemas/BidStrategy"}],"deprecated":true,"description":"Deprecated: send it inside `platformSpecificData` instead (Meta today; TikTok's nested shape is planned). The flat field keeps working during the deprecation window; sending both shapes returns a 400.\n\nMeta bid strategy applied to the ad set. On TikTok, mapped to\n`bid_type` / `bid_price` / `deep_bid_type` automatically.\n"},"bidAmount":{"type":"number","deprecated":true,"description":"Deprecated: send it inside `platformSpecificData` instead (Meta today; TikTok's nested shape is planned). The flat field keeps working during the deprecation window; sending both shapes returns a 400.\n\nBid cap in WHOLE currency units (USD: 5 = $5.00; JPY: 100 = ¥100). Required when\n`bidStrategy` is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`. Backward-compat: providing\n`bidAmount` without `bidStrategy` is treated as `LOWEST_COST_WITH_BID_CAP`.\n"},"roasAverageFloor":{"type":"number","deprecated":true,"description":"Deprecated: send it inside `platformSpecificData` instead (Meta today; TikTok's nested shape is planned). The flat field keeps working during the deprecation window; sending both shapes returns a 400.\n\nMinimum ROAS as a decimal multiplier (e.g. 2.0 = 2.0x ROAS). Required when\n`bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`. Sent to Meta as\n`bid_constraints.roas_average_floor` × 10000 (Meta uses fixed-point integers).\n"},"platformSpecificData":{"description":"Platform-specific options. The platform is derived from `accountId`;\nsending options for a different platform returns a 400. LinkedIn\n(campaign bidding and delivery controls) and Meta (the bid trio)\nhave options today.\n\n**Meta**: `bidStrategy`, `bidAmount` and `roasAverageFloor` may be\nsent here instead of at the root, which is the preferred home going forward.\nSending the bid fields in BOTH places returns a 400\n(`mutually_exclusive_fields`).\n","oneOf":[{"$ref":"#/components/schemas/LinkedInAdsPlatformData"},{"$ref":"#/components/schemas/MetaAdsPlatformData"}]},"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"}},"regionalRegulatedCategories":{"type":"array","description":"Meta only. Regional regulation categories required when the ad set targets certain countries (e.g. BRAZIL_REGULATION, SINGAPORE_UNIVERSAL, TAIWAN_UNIVERSAL, THAILAND_UNIVERSAL, AUSTRALIA_FINSERV, INDIA_FINSERV, TAIWAN_FINSERV). Forwarded to the ad set.","items":{"type":"string"}},"regionalRegulationIdentities":{"type":"object","additionalProperties":{"type":"integer"},"description":"Meta only. Beneficiary/payer entity IDs for regionalRegulatedCategories. Values are numeric IDs from Meta verification. Keys vary by category (e.g. universal_beneficiary / universal_payer for BRAZIL_REGULATION and THAILAND_UNIVERSAL). If omitted, Meta uses Ads Manager defaults when configured."},"linkUrl":{"type":"string","format":"uri","description":"Website URL for non-messaging CTA buttons. Send it with `callToAction`. Omit for messaging boosts.\n\n**Meta**: adds a top-level `call_to_action` to the post-reference creative.\nThis is what gives a `traffic` boost a clickable destination without\nreplacing the creative and losing the post's social proof. Ignored when\n`leadGenFormId` is set, which supplies its own destination. Live-verified\nagainst a Page-post creative.\n\n**TikTok**: maps to `landing_page_url` on the Spark Ad creative\n(`AdcreateCreatives.landing_page_url`); Spark Ads have no clickable\ndestination without it.\n\nIgnored on LinkedIn / Pinterest / X / Google, which infer the destination\nfrom the boosted post.\n"},"callToAction":{"type":"string","description":"CTA button label. Non-messaging CTAs require `linkUrl`.\nWHATSAPP_MESSAGE, MESSAGE_PAGE, and INSTAGRAM_MESSAGE do not\nrequire a URL and reject linkUrl.\n\n**Meta**: the CTA enum of POST /v1/ads/create plus\n`VIEW_INSTAGRAM_PROFILE`, `WHATSAPP_MESSAGE`, `MESSAGE_PAGE`,\nand `INSTAGRAM_MESSAGE`. VIEW_INSTAGRAM_PROFILE requires linkUrl;\nthe messaging CTAs select their destination automatically.\n\n**TikTok**: pass-through to `call_to_action` on the Spark Ad creative; the\nplatform validates the value. See TikTok's \"Enumeration - Call-to-Action\".\n"},"sparkAuthCode":{"type":"string","description":"TikTok-only. Spark Code (creator's `auth_code`) authorizing cross-creator\nSpark Ads: the advertiser can boost a video owned by a DIFFERENT TikTok\naccount. Without this, boosts are limited to videos owned by the same\naccount running the ads (same-BC creators only). The creator generates the\ncode in their TikTok app's Promote settings and shares it with the\nadvertiser. Maps to `auth_code` on the creative entry of /v2/ad/create/.\n"},"promotedObject":{"type":"object","description":"TikTok-only on this endpoint. The pixel a Website Conversion ad group\noptimizes toward, so a Spark Ad built from an existing organic post can\noptimize for a conversion instead of only engagement or traffic.\n\nRequired when `goal` is `conversions`, and BOTH fields are required:\nTikTok refuses a conversion ad group with no pixel (\"Please select a\npixel\") and equally one that has a pixel but no event (\"Select a pixel\nevent.\"), because the event is what the ad group optimizes toward. Ignored\non every other goal, since only a WEB_CONVERSIONS ad group accepts them.\n\nCombine freely with `platformPostId` + `sparkAuthCode`: the pixel lives on\nthe ad group and the Spark item on the creative, so they never conflict.\n","required":["pixelId","customEventType"],"properties":{"pixelId":{"type":"string","description":"TikTok Pixel. Either the numeric pixel id or the alphanumeric pixel code from Events Manager, which is resolved for you."},"customEventType":{"type":"string","description":"Optimization event, as a TikTok optimization_event code (e.g. ON_WEB_ORDER, SHOPPING, FORM) or the exact event name shown in Events Manager, which is resolved to its code. The event must already exist on that pixel, or TikTok rejects the ad group."}}},"dsaBeneficiary":{"type":"string","maxLength":100,"description":"Legal entity that benefits from the ad. Required when targeting EU users\n(EU DSA, Article 26). Optional if the ad account has a default beneficiary:\nset it once via `PATCH /v1/ads/accounts` or in Meta Ads Manager, and Meta\nfills it in whenever the field is omitted.\n"},"dsaPayor":{"type":"string","maxLength":100,"description":"Legal entity that pays for the ad. Can differ from `dsaBeneficiary`\n(for example, an agency paying for a client's ads). Same rules as\n`dsaBeneficiary`: required for EU targeting unless the ad account has\na default payor.\n"},"leadGenFormId":{"type":"string","description":"Lead Gen form ID to attach to the boosted ad's creative. REQUIRED when `goal` is `lead_generation`. On Meta this is the leadgen_forms ID (create one via POST /v1/ads/lead-forms). On LinkedIn this is the adForm ID (create one via POST /v1/ads/lead-forms with a LinkedIn account); the creative's `leadgenCallToAction.destination` is set to `urn:li:adForm:{id}`. Ignored for other goals."},"status":{"type":"string","enum":["ACTIVE","PAUSED"],"description":"Meta, TikTok, and LinkedIn. Publish state of the created entities. Omitted or ACTIVE publishes live (default); PAUSED creates them paused so you can review before they spend. On Meta a new campaign stays paused until explicitly activated; an attached ad is itself paused. On LinkedIn the whole campaign group, campaign, and creative hierarchy stays PAUSED (intendedStatus PAUSED on each)."},"optimizationGoal":{"type":"string","description":"Meta only. Explicit ad-set `optimization_goal` override. When omitted,\ndefaults to the value derived from `goal`. Messaging boosts always\nuse CONVERSATIONS and reject another optimizationGoal. Otherwise the value must be compatible\nwith the objective Meta derives from `goal`, not with the objective used\nby `POST /v1/ads/create` for the same `goal` name: boost maps `goal:\n\"engagement\"` to objective `OUTCOME_AWARENESS`, which accepts\n`REACH`, `IMPRESSIONS`, `AD_RECALL_LIFT`, or THRUPLAY-class values, and\nrejects `POST_ENGAGEMENT` (that value is only valid under\n`OUTCOME_ENGAGEMENT`, which create uses for the same goal name).\n"}}},"example":{"accountId":"69ca62446cb7b8cf4cb13603","adAccountId":"act_757082720485182","platformPostId":"811889972008357_123456789","name":"WhatsApp post boost","goal":"engagement","callToAction":"WHATSAPP_MESSAGE","whatsappPhoneNumber":"+12025550123","budget":{"amount":2.61,"type":"daily"},"status":"PAUSED"}}}},"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. Also returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"description":"The account may also be inactive or need reconnection (code ads_connection_required). Reconnect it and read GET /v1/accounts for its current ID before retrying.\nAn identical boost request is already in progress (with or without\nan Idempotency-Key). Wait for it to finish instead of retrying.\n"},"422":{"description":"Platform ads connection required (TikTok Ads, X Ads), missing linked\naccount, or (for TikTok) the connected TikTok user is not authorized\nas an Identity on the target advertiser. Returned with code\n`ads_connection_required`; the message includes the actionable\n\"TikTok Ads Manager → Assets → Identity\" remediation step.\nAlso returned as `idempotency_key_reused` when an Idempotency-Key\nis reused with a different request body.\n"}}}},"/v1/ads/campaigns/{campaignId}/asset-groups":{"get":{"operationId":"listGoogleAssetGroups","summary":"List Performance Max asset groups","tags":["Ad Campaigns"],"x-platforms":["google"],"x-resource-group":"ads","description":"Read Performance Max asset groups and their linked text, image and YouTube assets. campaignId is the platform campaign id returned by creation or the campaign list. The campaign must be visible to the caller. Uses a 10-minute cache, with the last successful response served as stale when Google quota is exhausted. Removed groups and asset links are excluded. Campaign-level brand assets on campaigns with brand guidelines enabled are not included.","security":[{"bearerAuth":[]}],"parameters":[{"name":"campaignId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Google Ads campaign id."}],"responses":{"200":{"description":"Asset groups and linked assets.","content":{"application/json":{"schema":{"type":"object","required":["assetGroups","cachedAt","stale"],"properties":{"assetGroups":{"type":"array","items":{"$ref":"#/components/schemas/GooglePmaxAssetGroup"}},"cachedAt":{"type":["string","null"],"format":"date-time"},"stale":{"type":"boolean"}}},"example":{"assetGroups":[{"id":"123456789","resourceName":"customers/9122445560/assetGroups/123456789","name":"Social publishing","status":"ENABLED","finalUrls":["https://zernio.com"],"assets":[{"resourceName":"customers/9122445560/assets/987654321","fieldType":"HEADLINE","status":"ENABLED","text":"Schedule posts"}]}],"cachedAt":"2026-09-10T09:00:00Z","stale":false}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/NotFound"},"429":{"description":"Google quota or operation budget exhausted with no cached response."},"501":{"description":"Campaign is not on Google Ads."}}}},"/v1/ads/create":{"post":{"x-resource-group":"ads","operationId":"createStandaloneAd","tags":["Ad Campaigns"],"x-platforms":["meta","google","tiktok","linkedin","pinterest","x","openai"],"summary":"Create standalone ad","description":"Create a paid ad with custom creative across Meta, Google Ads, Pinterest, TikTok, X, LinkedIn, and OpenAI Ads (ChatGPT Ads).\n\nGoogle Performance Max: set `campaignType: \"pmax\"` and supply `assetGroup` with\ntext, images by role, business name and finalUrl. Creates a daily budget, PAUSED\ncampaign and asset group atomically. `validateOnly: true` validates the complete\nrequest with Google without creating or persisting resources. Read assets with\n`GET /v1/ads/campaigns/{campaignId}/asset-groups`. The logo is required; video is\noptional via `assetGroup.youtubeVideoId`. Brand guidelines are disabled at creation.\nAll supplied asset links are validated together against Google's minimum asset requirements.\nPMax rejects ACTIVE creation, portfolio bidding, bid caps, legacy creative fields\nand attach shapes. Geo and language targeting are supported; omitted geo targets\nall locations. PMax does not require top-level goal, headline, body or linkUrl.\nSupported bidding: omitted or LOWEST_COST_WITHOUT_CAP for Maximize Conversions,\nCOST_CAP plus bidAmount for target CPA, LOWEST_COST_WITH_MIN_ROAS plus\nroasAverageFloor for Maximize Conversion Value with target ROAS.\n\nOther mutually-exclusive request shapes are selected by the body:\n\n- Legacy single-creative shape (all platforms, the default).\n- Meta-only multi-creative shape via the creatives array: one ad set with N ads sharing budget and targeting.\n- Attach shape via adSetId: adds one new ad to an existing ad set, inheriting its budget, targeting, and schedule (Meta, Google Ads, TikTok, and LinkedIn). On LinkedIn adSetId is the existing Campaign id, and the budget, schedule, targeting and bidding fields must be omitted.\n\nMeta accepts `creativeFeatures` on the single and attach shapes and as defaults for\n`creatives[]`; an item replaces the whole feature map. `promotion` is not supported on any\nshape and any object is rejected with 400.\nReusing `existingCreativeId` uses the existing creative settings instead of new settings.\nRequested settings are persisted for lists, exports, and default ad-detail reads.\n\nPer-platform required fields, budget minimums, and video-ad rules are documented on each property below.\n\nLinkedIn 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`.\n\n**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.\n","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":{"$ref":"#/components/schemas/AdTracking"},"goal":{"type":"string","enum":["engagement","traffic","awareness","video_views","lead_generation","lead_conversion","conversions","app_promotion","catalog_sales","page_likes","job_applicants"],"description":"Required on legacy and multi-creative shapes; the attach shape inherits it from the ad set. Available goals vary by platform.\n\n**Meta**\n- `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.\n- `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`.\n- `lead_generation`: OUTCOME_LEADS with instant forms. Requires `leadGenFormId`. `promotedObject.pageId` is optional and auto-filled from the connected Page.\n- `app_promotion`: requires `promotedObject.applicationId` and `promotedObject.objectStoreUrl`.\n- `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.\n- `page_likes`: Page Likes conversion location under OUTCOME_ENGAGEMENT (destination_type ON_PAGE, optimization PAGE_LIKES). `promotedObject.pageId` is optional and auto-filled from the connected Page. The creative CTA is fixed to LIKE_PAGE targeting that Page; headline / body / linkUrl / callToAction / imageUrl / video are all optional (Meta derives the link and the Like button from the Page).\n\n**TikTok**\n- `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.\n\n**LinkedIn**\n- `engagement`, `traffic`, `awareness` and `video_views` create standalone Direct Sponsored Content ads. `traffic` requires `linkUrl`; `video_views` requires `video`.\n- `lead_generation`: requires `leadGenFormId` (an adForm ID from POST /v1/ads/lead-forms). The campaign objective is set to MAX_LEAD and the creative's `leadgenCallToAction` destination is set to `urn:li:adForm:{id}`.\n- `job_applicants` requires a `platformSpecificData.jobs` creative.\n- For `conversions` on LinkedIn, or to promote an existing post, use POST /v1/ads/boost.\n\n**OpenAI Ads**\n- 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.\n"},"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"],"default":"AUCTION","description":"Meta only. Defaults to AUCTION and is explicitly sent on new campaigns, including validateOnly. Reusing existingCampaignId does not change the campaign. 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, so pass that one). Requires buyingType RESERVED."},"promotion":{"$ref":"#/components/schemas/MetaPromotion","description":"Not supported. Meta validates creative_sourcing_spec.promotion_metadata_spec on the create call and then discards it, so a Promotion set through the Marketing API never reaches the creative. Any object is rejected with 400 invalid_field_value. Send null or omit the field, and set the Promotion on the ad in Ads Manager. Verified on 2026-09-11 across Graph v19.0 to v25.0 and every write path."},"creativeFeatures":{"$ref":"#/components/schemas/MetaCreativeFeatures","description":"Meta only. Applied to each new creative, including standalone and attach shapes. With creatives[], these are defaults; an item replaces the whole feature map, including an empty map. auto_promotion_tag is an Advantage+ enhancement, not the Ads Manager Promotion setting."},"multiAdvertiser":{"type":"string","enum":["OPT_IN","OPT_OUT"],"description":"Meta only. Multi-advertiser ads: whether Meta may show this ad alongside other advertisers' in one unit. Meta auto-enrols since Aug 2024, so send OPT_OUT to leave. It is a top-level creative field, NOT a `creativeFeatures` key, and Meta rejects it there."},"validateOnly":{"type":"boolean","description":"Google Performance Max validates the complete atomic campaign and asset group with no resource creation or local persistence. Google validation still downloads image URLs and consumes quota. On Meta, validates the complete inline campaign, ad set, creative and ad with execution_options validate_only. Nothing is uploaded or created, and validation bypasses Idempotency-Key storage. Supports a single image, all-image placementAssets with per-rule copy, existing video.id or existingCreativeId; other media pools, new video uploads, creatives[], adSetId and RESERVED buying return 400. Placement validation uses existing Instagram identities only. Existing campaign or creative nodes are marked skipped. Success returns 200 with per-node results; Meta rejection returns an error."},"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 and Performance Max 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 and Performance Max 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":"Google Performance Max accepts PAUSED only and always creates a paused campaign. Google Search and Display, Meta, TikTok, and LinkedIn: publish state of the created entities. Omitted or ACTIVE publishes live (default, back-compat); PAUSED creates them paused so you can review before they spend. On Meta the pause is held on the campaign this call creates, leaving the ad set and ad switched on, so a single PUT /v1/ads/campaigns/{campaignId}/status with `active` brings the whole thing live. It is held at every level instead when the pause cannot rely on the campaign: `existingCampaignId` (that campaign may be running and is never touched) or `campaignStatus: ACTIVE`. Google Search and Display follow the same rule, and because Google keeps an independent switch at campaign, ad group and ad level, a PAUSED create leaves the campaign it creates PAUSED at Google. On TikTok the whole campaign > ad group > ad hierarchy stays paused. On LinkedIn the whole campaign group, campaign, and creative hierarchy stays PAUSED (intendedStatus PAUSED on each)."},"campaignStatus":{"type":"string","enum":["ACTIVE","PAUSED"],"description":"Meta and Google. Overrides `status` for the campaign level alone, so you can create a live campaign whose ad set and ad stay paused, or the reverse. Omitted, it follows `status`."},"budgetLevel":{"type":"string","enum":["adset","campaign"],"default":"adset","description":"Meta only. Where the budget lives, which selects the Meta budget model:\n  - `adset` (default): ABO (Ad-set Budget Optimization). The budget is set on the\n    ad set. This is the back-compatible behaviour; omit this field to keep it.\n  - `campaign`: CBO (Campaign Budget Optimization / Advantage Campaign Budget). The\n    budget AND `bidStrategy` are set on the CAMPAIGN, and Meta distributes spend\n    across ad sets automatically.\nThe returned ad stores the applied `budgetLevel` and budget in `campaignBudget`\nfor CBO or `adSetBudget` for ABO. Edit CBO budgets with\n`PUT /v1/ads/campaigns/{campaignId}` and ABO budgets with\n`PUT /v1/ads/ad-sets/{adSetId}`.\nMeta requires the budget at exactly one level, never both. Non-Meta platforms ignore\nthis field. Ignored on the attach shape (`adSetId`), which inherits the existing budget.\n"},"currency":{"type":"string","minLength":3,"maxLength":3,"description":"ISO 4217 currency code matching the ad account's currency (e.g. `USD`). Meta only. Optional: Zernio resolves it from the ad account when omitted. The value selects the minor-unit exponent Zernio converts budget/bid amounts by before calling Meta (most currencies are cents; zero-decimal currencies like JPY/KRW are sent as-is)."},"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. 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 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 `descriptions` (array) instead."},"bodies":{"type":"array","minItems":1,"maxItems":5,"items":{"type":"string"},"description":"Meta only. Multiple Text Options (Advantage+ Flexible Format): supply 1-5 primary-text\nvariations and Meta optimises delivery across them, WITHOUT enabling full Dynamic Creative\n(`dynamicCreative`). Uses `optimization_type: DEGREES_OF_FREEDOM` on the asset feed, so\nmultiple ads per ad set are allowed (unlike `dynamicCreative` which is limited to one).\nRequires `imageUrl` or `video`, `linkUrl`, and `callToAction`. When set, the top-level\n`body` field is used as the `object_story_spec.link_data.message` (the preview text) and\n`headlines` must also be present. On a video creative the copy lands in\n`video_data.message` / `video_data.title` instead of `link_data`. Mutually exclusive\nwith `dynamicCreative`, `placementAssets`, `carouselCards`, and `creatives[]`. For placement-specific copy, use the singular `placementAssets.rules[].body` and `headline` fields instead.\n"},"headlines":{"type":"array","minItems":1,"maxItems":5,"items":{"type":"string","maxLength":255},"description":"Meta only. Headline variations for Multiple Text Options. Must be sent alongside `bodies`.\nThe top-level `headline` field is used as the `object_story_spec.link_data.name`\n(`video_data.title` on a video creative).\n"},"descriptions":{"type":"array","minItems":1,"maxItems":5,"items":{"type":"string","maxLength":255},"description":"Meta only. Optional description variations for Multiple Text Options. Sent alongside `bodies` and `headlines`."},"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."},"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":"Lead Gen form ID to attach to the ad's creative. REQUIRED when `goal` is `lead_generation`. Create one via POST /v1/ads/lead-forms. On Meta (facebook/instagram) this is the leadgen_forms ID; the ad set's promoted_object.page_id + LEAD_GENERATION optimization + destination_type ON_AD are derived automatically from the goal. On LinkedIn this is the adForm ID; the creative's `leadgenCallToAction.destination` is set to `urn:li:adForm:{id}` and the campaign objective is set to MAX_LEAD. Forms must be owned by the sponsoredAccount (not the organization) for the URN to resolve. Also required on every Meta ATTACH (`adSetId`) call that targets a lead ad set (the form attaches per-ad; Meta rejects a formless ad in a lead ad set). `placementAssets`, `dynamicCreative` and `carouselCards` (Meta multi-card Instant-Form lead ad; `linkUrl` and per-card `linkUrl` are optional and forwarded as real destinations when sent, falling back to Meta's lead-form link when omitted) ARE supported on Meta instant-form lead ads."},"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. 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. Creates a single VIDEO ad. Mutually exclusive with `imageUrl`. Supply `url` to upload a file, or `id` to reuse a video already on the ad account (list them with GET /v1/ads/videos). Works on the single-ad and attach (`adSetId`) shapes; 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 usually publish without supplying one. When Meta produces no candidate the request fails with a 502 platform_error (reason: video_thumbnail_unavailable). Retry, or supply this field. 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:\ncreates 1 campaign + 1 ad set + N ads (one per entry here).\nTop-level `headline` / `body` / `imageUrl` / `linkUrl` /\n`callToAction` are ignored in this mode. Mutually exclusive with `adSetId`.\n","items":{"type":"object","required":["headline","body","linkUrl","callToAction"],"description":"Each creative must supply EXACTLY ONE of `imageUrl` (image creative) or `video` (video creative).","properties":{"creativeFeatures":{"$ref":"#/components/schemas/MetaCreativeFeatures","description":"Replaces the entire top-level creativeFeatures map for this item. Omit to inherit; an empty map clears these defaults."},"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, and the request fails with a 502 platform_error (reason: video_thumbnail_unavailable) if Meta produces no candidate.","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\none new ad to this existing ad set without creating a new\ncampaign. Budget, targeting, goal, schedule, AND bid strategy\nare inherited from the ad set on Meta, and passing `bidStrategy`\nin attach mode returns 400. To change an existing ad set's\nbid, use `PUT /v1/ads/ad-sets/{adSetId}`. Mutually exclusive\nwith `creatives[]`. `dynamicCreative` returns 400 in attach mode: create\na new dynamic ad set by omitting `adSetId` instead.\n\nThe attached ad takes the full single-creative surface:\n`headline`/`body`/`description`/`callToAction` plus either\n`imageUrl`/`video` OR `placementAssets` (its own per-placement\nFeed/Story assets) OR `translations`/`defaultLocale` (its own\nper-locale asset feed, Meta only), and `leadGenFormId` when\nthe target is a lead ad set (the parent must be ON_AD, true for ad sets\ncreated via goal `lead_generation`; Meta rejects a formless ad\nthere, so pass the form on EVERY attached ad). This is the way\nto build N full ads sharing one ad set: create the first ad\nvia the normal shape, then attach the rest one call each.\n\nSupported on Meta (facebook, instagram), Google Ads, TikTok,\nand LinkedIn. On TikTok the `adSetId` is the ad group ID; the\nnew ad inherits the ad group's bid + budget + targeting.\nOn LinkedIn the `adSetId` is the LinkedIn Campaign ID\n(numeric); we attach a new Creative to that Campaign, so\nthe Campaign's `platformSpecificData` bidding, targeting,\nbudget and schedule are inherited (passing those fields\nreturns 400).\n\nOn Google Ads the `adSetId` is the AD GROUP id. `goal` is\nstill REQUIRED even though budget and targeting are\ninherited from the ad group. Send `campaignType: \"search\"`\nto attach into a Search ad group, including one created by\n`POST /v1/ads/ad-sets` (always SEARCH_STANDARD): without it\nthe request is treated as Display and requires\n`images.landscape` + `images.square` + `businessName`, and\nthe resulting display creative does not match a Search ad\ngroup.\n`budgetAmount`/`budgetType` and bidding fields\n(`bidStrategy`, `bidAmount`, `portfolioBidStrategyId`)\nreturn 400 on this shape; the ad group already owns them.\n"},"existingCampaignId":{"type":"string","description":"Meta, Google Ads, and LinkedIn. On Meta: add the new ad\nset under this EXISTING campaign instead of creating a new\none (multi-ad-set audience testing). The new ad set's\nbudget is matched to the campaign's mode automatically:\nfor a CBO campaign (campaign-level budget) omit\n`budgetAmount`/`budgetType`, since the campaign owns the\nbudget; for an ABO campaign pass them (they go on the new\nad set). On LinkedIn: create a new Campaign (and its\nCreative) under this EXISTING CampaignGroup. On Google\nAds: create a new ad group under this EXISTING campaign;\nthe new ad group inherits the campaign's budget, so omit\n`budgetAmount`/`budgetType` (and any bidding field), or\nthe request returns 400. On failure only the entities we\nauthored are cleaned up; the pre-existing parent is left\nuntouched and is never (re)activated. Mutually exclusive\nwith `adSetId` and `creatives[]`.\n"},"existingCreativeId":{"type":"string","description":"Meta only. Reuse an EXISTING ad creative by id instead of\nbuilding a new one from the copy/media fields (which are then\nignored). Combine with `existingCampaignId` to build a\nmulti-ad-set campaign that shares one creative. Mutually\nexclusive with `creatives[]`, `dynamicCreative`, and\n`placementAssets`. The creative id used is returned as\n`creativeId` on the create response.\n"},"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`,\n`POST /v1/ads/targeting/reach-estimate`, and `saved_targeting` audiences. Merged\nUNDER the flat inline targeting fields below: `savedTargetingId` < `targeting` <\nflat fields (a flat field present on the body replaces the nested value entirely).\nBoth forms are equivalent; use whichever your integration already builds.\n","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.)"},"countryGroups":{"type":"array","description":"Meta only. Continents and trade blocs (`geo_locations.country_groups`),\nfor targeting a whole region without listing its countries. Combines with\n`countries` rather than replacing it. Discoverable via\n`GET /v1/ads/targeting/search?dimension=geo&geoType=country_group`.\n","items":{"type":"string","enum":["africa","asia","europe","north_america","south_america","oceania","central_america","caribbean","eea","euro_area","nafta","mercosur","afta","apec","gcc","cisfta","emerging_markets","itunes_app_store","android_free_store","android_paid_store"]}},"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).\n\nOn 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`.\n","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>`.\n","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"}}}},"workPositions":{"type":"array","description":"Meta only. Job title entities from /v1/ads/targeting/search?dimension=workPosition. Each must include id. Rejected on other platforms (use LinkedIn's `jobTitles` there).","items":{"type":"object","required":["id"],"properties":{"id":{"type":"string"},"name":{"type":"string"}}}},"workEmployers":{"type":"array","description":"Meta only. Employer entities from /v1/ads/targeting/search?dimension=workEmployer. Each must include id.","items":{"type":"object","required":["id"],"properties":{"id":{"type":"string"},"name":{"type":"string"}}}},"workIndustries":{"type":"array","description":"Meta only. Work-industry entities from /v1/ads/targeting/search?dimension=workIndustry. Each must include id. Rejected on other platforms (use LinkedIn's `industries` there).","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\n`top_10`; rejected on LinkedIn, X, and Pinterest. On Meta, income targeting is incompatible\nwith housing/employment/credit `specialAdCategories`.\n"},"languages":{"type":"array","items":{"type":"string"},"description":"e.g. [\"en\",\"es\"]. Google: campaign language targeting (language_constant) using Google's language codes (ISO 639-1, plus variants such as `zh_CN`); unknown codes return 400. On Meta, 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,\nrecommended for most cases, since Meta optimises delivery across all eligible surfaces).\nWhen set, restricts delivery to the chosen surfaces, mapped onto the ad set's\n`targeting.{publisher_platforms, facebook_positions, instagram_positions,\nmessenger_positions, audience_network_positions, threads_positions,\nwhatsapp_positions, device_platforms}`. Enum membership is validated here; Meta\nadditionally enforces co-selection rules (e.g. some positions require their parent\npublisher platform) and returns an actionable error which we surface. Non-Meta\nplatforms reject this field.\n","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\nTargetingSpec is expanded as the base targeting; inline fields on this body merge on top. Lets you\nreuse a named targeting preset without re-sending every field.\n"},"rawTargeting":{"type":"object","additionalProperties":true,"description":"Meta only. A raw Meta-native targeting spec (snake_case: `geo_locations`, `age_min`,\n`excluded_custom_audiences`, `flexible_spec`, `targeting_automation`, `user_os`,\n`wireless_carrier`, business places, etc.), exactly the shape `GET /v1/ads/{adId}` returns for\nexternal ads. Sent alone it reaches the ad set VERBATIM (the clone-a-campaign's-targeting-exactly\npath). Meta validates and surfaces any errors.\n\nCan be combined with the camelCase targeting fields (countries/regions/cities/interests/ageMin/...,\n`targeting`, `savedTargetingId`, `audienceId`): rawTargeting is the BASE layer and the built\ncamelCase spec is merged on top, key by key, with the camelCase side winning on collision (the\ncamelCase precedence chain stays `savedTargetingId` < `targeting` < flat fields). The merge goes\none level deep inside `geo_locations` and `excluded_geo_locations`: built sub-keys win, raw-only\nsub-keys such as `location_types` survive alongside built `countries`. Array values\n(`flexible_spec`, ...) are replaced as a WHOLE key when the camelCase spec builds them, never\nelement-merged. When rawTargeting is present the defaults the camelCase builder normally injects\n(US geo, `targeting_automation.advantage_audience: 0`) are suppressed, so raw's values are not\nclobbered. Include `targeting_automation` in the raw spec (or send `advantageAudience`) as Meta\nrequires it on create. If cloning an EU campaign, also pass `dsaBeneficiary` / `dsaPayor` (those\nare separate fields, not part of targeting).\n"},"specialAdCategories":{"type":"array","description":"Meta only. Declares the ad's special category, required for housing, employment, credit, or\npolitical/social-issue ads (Meta enforces restricted targeting for these). Note: setting a special\ncategory disables income/zip targeting on Meta.\n","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\nspecialAdCategories to be set (400 otherwise). Ignored when joining an existing campaign via\nexistingCampaignId (the existing campaign's category/country already governs it).\n","items":{"type":"string"}},"regionalRegulatedCategories":{"type":"array","description":"Meta only. Regional regulation categories required when the ad set targets certain countries.\nKnown values: BRAZIL_REGULATION, SINGAPORE_UNIVERSAL, TAIWAN_UNIVERSAL, THAILAND_UNIVERSAL,\nAUSTRALIA_FINSERV, INDIA_FINSERV, TAIWAN_FINSERV. Meta rejects the ad set without this when\nthe targeting geo includes the corresponding country.\n","items":{"type":"string"}},"regionalRegulationIdentities":{"type":"object","additionalProperties":{"type":"integer"},"description":"Meta only. Beneficiary/payer entity IDs for regionalRegulatedCategories. Values are\nnumeric IDs from Meta verification. Keys vary by category (e.g. universal_beneficiary /\nuniversal_payer for BRAZIL_REGULATION and THAILAND_UNIVERSAL). If omitted, Meta uses\nAds Manager defaults when configured.\n"},"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\nad set's `start_time`. When omitted the ad starts delivering immediately. For lifetime\nbudgets Meta also requires `endDate`. (Same `schedule.startDate` semantics already\navailable on `POST /v1/ads/boost`.)\n"},"instagramAccountId":{"type":"string","description":"Meta only. Override the Instagram account the ad is delivered as. Pass an Instagram\nBusiness Account ID (e.g. 17841...), mapped to the creative's `instagram_user_id`.\nWhen omitted we use the Instagram actor Meta already runs the Page's other ads as,\nfalling back to the Page's page-backed Instagram account. Useful when a Page has more\nthan one eligible IG account.\n"},"dynamicCreative":{"type":"object","description":"Meta only. Dynamic Creative: supply a POOL of assets and Meta auto-combines and\noptimises them into the best-performing variations within a single ad (mapped to the\ncreative's `asset_feed_spec`). When set, the top-level single-creative fields\n(`imageUrl`, `headline`, `body`, `linkUrl`, `callToAction`) are ignored. Mutually\nexclusive with the `creatives[]` multi-creative shape. Exactly ONE of `imageUrls` /\n`videoUrls` is required (Meta allows one ad format per asset feed; sending both →\n400). Limits remain 10 images or videos and 5 bodies, titles or descriptions.\nThe ad set is created with `is_dynamic_creative: true`. Combining this field\nwith `adSetId` returns 400: omit `adSetId` to create a new dynamic ad set.\nMultiple headlines go in `titles`; multiple primary texts go in `bodies`.\n","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\nthe creative's `link_data.child_attachments`. Unlike `dynamicCreative`,\nyou control the card order and per-card copy/link. Requires top-level `body`\nand `callToAction`; `linkUrl` is also required UNLESS `leadGenFormId` is set. Those\nbecome the ad's own Destination and button (`link_data.link` / `link_data.call_to_action`),\nand double as the per-card fallback when a card omits its own.\nMutually exclusive with `imageUrl`/`video`, `creatives[]`, `dynamicCreative`,\n`placementAssets`, `existingCreativeId`, `adSetId` and goal\n`catalog_sales`. Combines with `leadGenFormId` to build a carousel Instant-Form\nlead ad: `linkUrl` and per-card `linkUrl` become OPTIONAL and, when sent, are\nforwarded as the real card and top-level destinations; when omitted, the\ndestination falls back to Meta's lead-form link.\n","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, or Meta's lead-form link when leadGenFormId is set and neither is provided."},"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\nper-locale copy and, optionally, per-locale media: the \"Languages\" toggle in Ads\nManager. Keeps social proof (likes/comments/shares) on a SINGLE post instead of\nsplitting it across one ad per language.\n\nThe ad's top-level copy is the DEFAULT shown to every locale you do NOT list,\nand it counts as one of the language variants.\n\nIMPORTANT, and the opposite of what you might expect: text does NOT inherit.\nEvery entry must carry its own `headline`, `body` AND `description`, and all of\nthem must be DISTINCT from each other and from the ad's top-level copy. Meta\ndeduplicates identical strings inside the asset feed, so two locales sharing a\nstring collapse into one asset and the create fails with a misleading \"Too few\n... texts provided in asset creation\" (subcode 1885817) that names a field which\nis actually present. We validate this before calling Meta and return a 400\nnaming the offending locale and field. `description` is therefore effectively\nrequired on the ad whenever `translations` is present, even though it is\noptional otherwise.\n\nDo NOT list `defaultLocale` inside `translations`: Meta rejects the duplicate\nwith \"The language asset feed includes an unsupported targeting field\"\n(subcode 1885985).\n\nMedia DOES inherit and is uploaded once when shared, and `linkUrl` inherits\ntoo: each locale may name its own landing page and unlisted locales fall back\nto the ad's top-level `linkUrl`. Meta enforces\nDynamic Creative image dimensions on language feeds, so an `imageUrl` that\nworks on a normal ad may be rejected with \"The following images have invalid\ndimensions for Dynamic Creative\" (subcode 1885558). Video is not affected.\n\nMutually exclusive with `dynamicCreative`, `placementAssets`, `carouselCards`,\n`existingCreativeId` and `creatives[]`. Meta allows one `asset_feed_spec` shape per creative.\n","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\neach placement group on a SINGLE ad (e.g. a 9:16 on Stories/Reels and a 4:5 on Feed).\nThe same thing Meta Ads Manager produces with \"different creative per placement\",\nmapped to the creative's `asset_feed_spec` + `asset_customization_rules`. Deterministic\npinning, NOT the auto-optimizing pool of `dynamicCreative` (mutually exclusive). Works\non the legacy single shape AND the attach shape (`adSetId` + placementAssets adds one\nplacement-customized ad to an existing ad set, the way to build N per-placement ads\nsharing one ad set: create the first normally, attach the rest). Cannot be combined\nwith `creatives[]` or top-level `bodies`/`headlines`/`descriptions` arrays. Each rule\ncan override `headline`, `body` and `description` with one string per field. Omitted\nfields and unmatched placements use the top-level copy; `linkUrl` and `callToAction`\nremain shared. Zernio emits labelled text with `optimization_type: PLACEMENT`.\nMultiple text options rotating within a placement are not supported by this input. Each rule's `placements` accepts the same fields as the top-level\n`placements` object; Meta enforces co-selection rules and returns an actionable error.\n\nMeta controls text rendering by placement and format. Validation accepts these fields\nbut does not prove that every field appears in delivery. Preview the ad; put copy that\nmust always be visible into the image or video itself.\n\n`validateOnly: true` supports all-image placementAssets without uploading or creating\nanything. Video placement validation remains unsupported because it requires uploads.\n\nA block is all-image OR all-video, never mixed (Meta's asset_feed_spec carries one ad\nformat). Image mode: `defaultImageUrl` + `rules[].imageUrl`. Video mode:\n`defaultVideoUrl` + `rules[].videoUrl` (optional `thumbnailUrl`/`defaultThumbnailUrl`\nposters; Meta auto-generates when omitted). Exactly one catch-all default is required.\n","example":{"defaultImageUrl":"https://example.com/feed-4x5.jpg","rules":[{"imageUrl":"https://example.com/feed-4x5.jpg","headline":"Build with Zernio","body":"Schedule social posts from your app.","description":"One API for your social content.","placements":{"publisherPlatforms":["facebook"],"facebookPositions":["feed"]}},{"imageUrl":"https://example.com/story-9x16.jpg","headline":"Your next story","body":"Bring your social workflow to Zernio.","placements":{"publisherPlatforms":["facebook"],"facebookPositions":["story","facebook_reels"]}}]},"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."},"headline":{"type":"string","minLength":1,"maxLength":255,"description":"One headline pinned to this rule. Omit to inherit the top-level headline."},"body":{"type":"string","minLength":1,"description":"One primary text pinned to this rule. Omit to inherit the top-level body."},"description":{"type":"string","minLength":1,"maxLength":255,"description":"One link description pinned to this rule. Omit to inherit the top-level description."},"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","pmax"],"default":"display","description":"Google only. Performance Max requires assetGroup and is always created PAUSED."},"assetGroup":{"$ref":"#/components/schemas/GooglePmaxAssetGroupInput"},"keywords":{"type":"array","maxItems":1000,"items":{"$ref":"#/components/schemas/KeywordEntry"},"description":"Google Search only. Keywords on the new ad group; entries are strings (BROAD) or { text, matchType }. Editable later via PUT /v1/ads/{adId} targeting.keywords."},"negativeKeywords":{"type":"array","maxItems":1000,"items":{"$ref":"#/components/schemas/KeywordEntry"},"description":"Google Search only; other platforms return 400. Ad-group-level negative keywords on the new ad group. Editable later via PUT /v1/ads/{adId} targeting.negativeKeywords."},"campaignNegativeKeywords":{"type":"array","maxItems":1000,"items":{"$ref":"#/components/schemas/KeywordEntry"},"description":"Google Search only; other platforms return 400. Campaign-level negative keywords (campaign_criterion.negative), created alongside the ad group. Editable later via PUT /v1/ads/campaigns/{campaignId}/negative-keywords."},"additionalHeadlines":{"type":"array","items":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/GoogleRsaHeadline"}]},"description":"Google Search RSA only. Extra text assets as strings or objects with text and optional pinnedField. Existing string input remains supported. The effective create lists, including primary text and deduplication, must contain 3-15 headlines and 2-4 descriptions; excess entries return 400.","example":["Schedule Your Posts",{"text":"Build With Zernio","pinnedField":"HEADLINE_2"}]},"additionalDescriptions":{"type":"array","items":{"oneOf":[{"type":"string"},{"$ref":"#/components/schemas/GoogleRsaDescription"}]},"description":"Google Search RSA only. Extra text assets as strings or objects with text and optional pinnedField. Existing string input remains supported. The effective create lists, including primary text and deduplication, must contain 3-15 headlines and 2-4 descriptions; excess entries return 400.","example":["Build social publishing into your application.",{"text":"Connect social accounts with one API.","pinnedField":"DESCRIPTION_2"}]},"sitelinks":{"type":"array","minItems":2,"maxItems":20,"description":"Google Search only. Sitelink assets to create and attach at the campaign level.\nEach entry becomes an Asset (with sitelink_asset + Asset.final_urls) plus a\nCampaignAsset link (field_type SITELINK). Approval is async: Google reviews\nassets after creation; poll asset.policy_summary later to read the verdict.\nGoogle requires at least two sitelinks to surface them on an ad; four or more\nis Google's own recommendation for maximum visibility. The response's\ncreative.sitelinks[] echoes each input plus its Google resourceName.\n","items":{"type":"object","required":["text","linkUrl"],"properties":{"text":{"type":"string","minLength":1,"maxLength":25,"description":"The clickable link text shown under the ad. 25-char cap comes from Google."},"linkUrl":{"type":"string","format":"uri","description":"Final URL the sitelink navigates to."},"description1":{"type":"string","minLength":1,"maxLength":35,"description":"First description line under the link text (optional). 35-char cap."},"description2":{"type":"string","minLength":1,"maxLength":35,"description":"Second description line (optional; usually paired with description1)."}}}},"callouts":{"type":"array","minItems":1,"maxItems":20,"items":{"type":"string","minLength":1,"maxLength":25},"description":"Google Search only. Short callout texts (max 25 chars each) that appear as\nnon-clickable annotations under the ad, e.g. \"Free shipping\", \"24/7 support\".\nEach becomes one Asset (`callout_asset`) plus a CampaignAsset link with\nfield_type CALLOUT. Response's creative.callouts[] echoes each input plus\nits Google resourceName.\n"},"structuredSnippets":{"type":"array","minItems":1,"maxItems":20,"description":"Google Search only. Structured snippets: one header from Google's\npredefined list plus 3-10 values (max 25 chars each). Each becomes one\nAsset (`structured_snippet_asset`) plus a CampaignAsset link with\nfield_type STRUCTURED_SNIPPET.\n","items":{"type":"object","required":["header","values"],"properties":{"header":{"type":"string","enum":["Amenities","Brands","Courses","Degree programs","Destinations","Featured hotels","Insurance coverage","Models","Neighborhoods","Service catalog","Shows","Styles","Types"],"description":"One of Google's 13 predefined snippet headers."},"values":{"type":"array","minItems":3,"maxItems":10,"items":{"type":"string","minLength":1,"maxLength":25}}}}},"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, mapping 1:1 to Meta's\nad-set `attribution_spec`. Only honored for conversion goals (`conversions`,\n`lead_generation`, `app_promotion`); ignored for awareness/traffic/engagement.\nOmit to use Meta's default (`7-day click` + `1-day view`). Meta enforces the\nvalid combinations: `VIEW_THROUGH` only allows `windowDays: 1` (7d/28d view\nwindows were removed Jan 2026); `ENGAGED_VIDEO_VIEW` only `1` and only alongside\n`VIEW_THROUGH: 1`; `CLICK_THROUGH: 28` only on certain objectives. Invalid combos\nsurface as a Meta 400.\nExample: `[{ \"eventType\": \"CLICK_THROUGH\", \"windowDays\": 7 }, { \"eventType\": \"VIEW_THROUGH\", \"windowDays\": 1 }]`\n"},"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"}],"deprecated":true,"description":"Deprecated: send it inside `platformSpecificData` instead (Meta today; TikTok's nested shape is planned). The flat field keeps working during the deprecation window; sending both shapes returns a 400.\n\nMeta bid strategy applied to the ad set.\n\nOpenAI Ads: required on every ad group via this flat field, the only channel it supports (`platformSpecificData` is Meta/LinkedIn-only and returns 400 for OpenAI). No auto-bid option exists; send `LOWEST_COST_WITH_BID_CAP` or `COST_CAP` together with `bidAmount`, omitting it returns 400.\n\nGoogle (not deprecated there, this shared flat field is Google's only shape): applied to the campaign this call creates. On Google: LOWEST_COST_WITHOUT_CAP = Maximize Conversions, COST_CAP + bidAmount = Target CPA, LOWEST_COST_WITH_MIN_ROAS + roasAverageFloor = Target ROAS, LOWEST_COST_WITH_BID_CAP + bidAmount = Maximize Clicks with a CPC ceiling; portfolioBidStrategyId attaches a portfolio strategy instead. Omitted, the campaign falls back to a goal-based default.\n"},"bidAmount":{"type":"number","deprecated":true,"description":"Deprecated: send it inside `platformSpecificData` instead (Meta today; TikTok's nested shape is planned). The flat field keeps working during the deprecation window; sending both shapes returns a 400.\n\nBid cap in WHOLE currency units (USD: 5 = $5.00; JPY: 100 = ¥100). Required when\n`bidStrategy` is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`. Meta only: sending\n`bidAmount` WITHOUT `bidStrategy` requires `existingCampaignId` (400 otherwise),\nand sets the new ad set's cap under the joined campaign's COST_CAP /\nLOWEST_COST_WITH_BID_CAP parent. The strategy itself is inherited from the\ncampaign. Restating bidStrategy here is accepted but has no effect on the ad set.\n\nRejected with 400 in `adSetId` attach mode: that shape inherits its cap from\nthe platform. Use `PUT /v1/ads/ad-sets/{adSetId}` there instead.\n"},"roasAverageFloor":{"type":"number","deprecated":true,"description":"Deprecated: send it inside `platformSpecificData` instead (Meta today; TikTok's nested shape is planned). The flat field keeps working during the deprecation window; sending both shapes returns a 400.\n\nMinimum ROAS as a decimal multiplier (e.g. 2.0 = 2.0x ROAS). Required when\n`bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`. Sending it without `bidStrategy`\nis a 400. Sent to Meta as\n`bid_constraints.roas_average_floor` × 10000. Known gap: a CBO campaign's\nROAS floor lives on the campaign only (set via `POST /v1/ads/campaigns`);\nthere is no supported way to set it while joining a CBO campaign here.\n"},"portfolioBidStrategyId":{"type":"string","pattern":"^\\d+$","description":"Google Search and Display only. Performance Max rejects portfolio bidding. Attach an existing portfolio bid strategy (numeric id from GET /v1/ads/bid-strategies) to the new campaign instead of a standard one. Exclusive with bidStrategy."},"valueRuleSetId":{"type":"string","pattern":"^\\d+$","description":"Meta only (facebook, instagram; other platforms return 400). Value rule set\nto attach to the new ad set, from `/v1/ads/value-rule-sets`. Attachment is\ndriven by this id, so `valueRulesApplied` is optional alongside it.\n\nRejected with 400 in `adSetId` attach mode: that shape inherits the existing\nad set's attachment, so the field would be silently ignored. Use\n`PUT /v1/ads/ad-sets/{adSetId}` there instead.\n\nIgnored (stripped before the ad-set create) when `buyingType` is `RESERVED`:\nvalue rules only apply to auction ad sets on `LOWEST_COST_WITHOUT_CAP` or\n`COST_CAP`, and a Reach & Frequency reservation has no auction bid strategy.\n\nRead back with `GET /v1/ads/ad-sets/{adSetId}?fields=value_rule_set_id`; the\nattachment is not mirrored onto Zernio's ad documents.\n"},"valueRulesApplied":{"type":"boolean","description":"Meta only (facebook, instagram; other platforms return 400). Optional when\nattaching, and requires `valueRuleSetId`. `false` is REJECTED here with 400:\na newly created ad set has nothing to detach, so detaching lives on\n`PUT /v1/ads/ad-sets/{adSetId}`.\n"},"platformSpecificData":{"description":"Platform-specific options. The platform is derived from `accountId`;\nsending options for a different platform returns a 400. LinkedIn\n(campaign bidding and delivery controls) and Meta (the bid trio)\nhave options today.\n\n**Meta**: `bidStrategy`, `bidAmount` and `roasAverageFloor` may be\nsent here instead of at the root, which is the preferred home going forward.\nSending the bid fields in BOTH places returns a 400\n(`mutually_exclusive_fields`), and sending any of them in\n`adSetId` attach mode is a 400 too (the ad set already has its bid).\n`dailyMinSpendTarget` / `lifetimeMinSpendTarget` set the new ad set's\nminimum spend and live here only; they are rejected in `adSetId` attach\nmode as well.\n","oneOf":[{"$ref":"#/components/schemas/LinkedInAdsPlatformData"},{"$ref":"#/components/schemas/MetaAdsPlatformData"}]},"dsaBeneficiary":{"type":"string","maxLength":100,"description":"Legal entity that benefits from the ad. Required when targeting EU users\n(EU DSA, Article 26). Optional if the ad account has a default beneficiary:\nset it once via `PATCH /v1/ads/accounts` or in Meta Ads Manager, and Meta\nfills it in whenever the field is omitted.\n"},"dsaPayor":{"type":"string","maxLength":100,"description":"Legal entity that pays for the ad. Can differ from `dsaBeneficiary`\n(for example, an agency paying for a client's ads). Same rules as\n`dsaBeneficiary`: required for EU targeting unless the ad account has\na default payor.\n"},"brandIdentity":{"type":"object","description":"TikTok only. Synthetic Brand Identity used when the ad\nattributes to a CUSTOMIZED_USER (instead of a real TT_USER\n@username). Required on the FIRST CUSTOMIZED_USER ad on a\n`tiktokads` SocialAccount with no cached identity; omit on\nsubsequent ads (the identity is cached on the account after\nfirst creation). Non-TikTok platforms ignore this field.\n\nAlternative: configure once via `PATCH /v1/connect/tiktok-ads`,\nthen create ads without this field.\n","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:\n\n  - `TT_USER`: the posting account's open_id (real @username\n    branding). Requires a connected TikTok posting account\n    on the same profile.\n  - `CUSTOMIZED_USER`: synthetic Brand Identity (display\n    name + avatar). Requires a configured Brand Identity\n    (cached on the `tiktokads` SocialAccount via\n    `PATCH /v1/connect/tiktok-ads`) or an inline\n    `brandIdentity` to create one on the fly.\n\nWhen omitted, defaults to `TT_USER` if a posting account is\nconnected on this profile, else `CUSTOMIZED_USER`. Spark\nAds (`POST /v1/ads/boost`) always use `TT_USER` regardless\nof this field, because TikTok requires the original organic\npost's author identity for Spark.\n"},"smartPlus":{"type":"boolean","description":"TikTok only. Creates the ad as a TikTok Upgraded Smart+\ncampaign: TikTok automates targeting, bidding and delivery. Supports goals\n`conversions` (Smart+ Web Conversions), `lead_generation` (Smart+ Lead\nGeneration with a website form on `linkUrl`; TikTok Instant Forms not supported)\nand `app_promotion` (Smart+ App installs; the ad's destination is the app store,\nso `linkUrl` is not used). The web goals require `promotedObject.pixelId` AND\n`promotedObject.customEventType`; `app_promotion` requires\n`promotedObject.applicationId` instead.\nTargeting works like on any TikTok ad (defaults to `countries: [\"US\"]` when\nomitted); TikTok automates delivery within it.\nThe budget lives on the Smart+ campaign (Campaign Budget Optimization); a `lifetime`\nbudget additionally requires `endDate`. Cannot be combined with `adSetId`.\n"},"userOs":{"type":"array","minItems":1,"items":{"type":"string","minLength":1},"description":"Meta only. Operating systems and version ranges, such as iOS_ver_14.0_and_above or Android. Emitted as user_os. May also be supplied inside targeting."},"userDevice":{"type":"array","minItems":1,"items":{"type":"string","minLength":1},"description":"Meta only. Device models such as iPhone. Emitted as user_device. May also be supplied inside targeting."},"isSkadnetworkAttribution":{"type":"boolean","description":"Meta app promotion only. Immutable campaign flag. Set true for iOS 14+ SKAdNetwork campaigns and supply promotedObject.applicationId plus promotedObject.objectStoreUrl. The campaign receives promotedObject only when this flag is true. Cannot be changed on an existing campaign."},"campaignAttribution":{"type":"string","enum":["AEM","SKADNETWORK"],"description":"Meta ad-set attribution. Required as SKADNETWORK for iOS 14+ app promotion or a SKAdNetwork campaign. Requires AUCTION buying. Standalone Meta ad-set creation is not supported; use this field on /v1/ads/create."},"promotedObject":{"$ref":"#/components/schemas/AdPromotedObject"}}},"examples":{"performanceMax":{"summary":"Paused Performance Max campaign.","value":{"accountId":"69ce75d483e990e1c01ccfe4","adAccountId":"9122445560","name":"Social publishing","campaignType":"pmax","budgetAmount":1,"budgetType":"daily","status":"PAUSED","validateOnly":true,"countries":["US"],"languages":["en"],"assetGroup":{"finalUrl":"https://zernio.com","headlines":["Schedule posts","One social API","Build with Zernio"],"longHeadline":"Schedule social content from your app with Zernio","descriptions":["Connect your social accounts.","Publish and manage social content through one API."],"businessName":"Zernio","images":{"landscape":["https://example.com/landscape.png"],"square":["https://example.com/square.png"],"logo":["https://example.com/logo.png"]}}}},"appPromotion":{"value":{"accountId":"69fc524892b3d8e85f893e73","adAccountId":"act_757082720485182","name":"iOS app installs","goal":"app_promotion","isSkadnetworkAttribution":true,"campaignAttribution":"SKADNETWORK","buyingType":"AUCTION","billingEvent":"IMPRESSIONS","optimizationGoal":"APP_INSTALLS","promotedObject":{"applicationId":"123456789","objectStoreUrl":"https://apps.apple.com/us/app/id123456789"},"linkUrl":"https://apps.apple.com/us/app/id123456789","headline":"Install our app","body":"Get started today.","callToAction":"INSTALL_MOBILE_APP","imageUrl":"https://example.com/app.jpg","targeting":{"countries":["US"],"userOs":["iOS_ver_14.0_and_above"]},"tracking":{"urlTags":[{"key":"utm_content","value":"{{ad.id}}"}]},"budgetAmount":1,"budgetType":"daily","status":"PAUSED","validateOnly":true}},"retailPromotion":{"value":{"accountId":"69fc524892b3d8e85f893e73","adAccountId":"act_123456789","name":"Autumn promotion","goal":"traffic","budgetAmount":5,"budgetType":"daily","status":"PAUSED","headline":"Save on your next order","body":"Use SAVE20 at checkout.","callToAction":"SHOP_NOW","linkUrl":"https://example.com/shop","imageUrl":"https://example.com/ad.jpg","creativeFeatures":{"auto_promotion_tag":"OPT_OUT"}}}}}}},"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","performanceMaxCampaign"]},"status":{"type":"string","enum":["validated","skipped"]},"reason":{"type":"string","description":"Why the node could not be validated (on skipped), or what the dry run could not check and what the request would do as sent (on validated). A Performance Max validation with no location targeting reports here that the campaign would run worldwide."}}}},"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. Also returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"422":{"description":"Platform ads connection required (TikTok Ads, X Ads) or missing linked account"},"501":{"description":"The requested option is not supported on this platform: `validateOnly` outside Meta,\nor a shape the adapter does not implement. Carries code `feature_not_available`.\n"},"502":{"description":"The platform rejected the request, or failed to produce media the ad\nneeds (e.g. Meta generated no poster for an uploaded video when no\n`video.thumbnailUrl` was supplied). Inspect `platformError` for the\nupstream payload. Failures we raise carry a `reason`; a payload\nforwarded verbatim from Meta may not. On the `creatives[]` shape a\nmissing poster also carries `creativeIndex` and `videoUrl` to\nidentify the entry. An upstream 4xx status is forwarded instead\nof 502.\n"},"503":{"$ref":"#/components/responses/TemporarilyUnavailable"}}}},"/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.\n","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","minimum":1,"maximum":253402300799,"example":1757404800},"description":"Unix seconds; only leads created at/after this timestamp. Millisecond timestamps return 400 with instructions to divide by 1000."},{"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. On LinkedIn, the leadFormResponse id."},"formId":{"type":"string"},"formName":{"type":["string","null"]},"accountId":{"type":"string"},"adId":{"type":["string","null"]},"adsetId":{"type":["string","null"]},"campaignId":{"type":["string","null"],"description":"On LinkedIn, this is the LinkedIn Campaign id, which corresponds to platformAdSetId on GET /v1/ads (LinkedIn's Campaign Group is Zernio's campaign)."},"isOrganic":{"type":"boolean"},"createdTime":{"type":["string","null"],"description":"ISO 8601."},"fields":{"type":"object","additionalProperties":{"type":"string"},"description":"Question key → answer. On LinkedIn, the key is the lowercased predefinedField, else the question name, else the numeric questionId; multiple-choice values are option labels (unlike Meta, which returns the option key)."},"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."},"502":{"$ref":"#/components/responses/UpstreamFailure"},"503":{"$ref":"#/components/responses/TemporarilyUnavailable"}}}},"/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. Pass either the `metaads` ads connection (the Page is taken from the Facebook account linked to it) or the Facebook account itself. LinkedIn: forms owned by the ad account's Company Page. Pass `adAccountId` (LinkedIn forms are org-owned). Requires the Ads add-on.\n","parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"Connected Meta ads, Facebook or LinkedIn ads account ID. A Meta ads connection resolves its Page through the Facebook account linked to the same profile."},{"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), where `accountId` may be the `metaads` ads connection (its Page comes from the Facebook account linked to the same profile) or the Facebook account itself; 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.\n","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"},"thankYouEnableMessenger":{"type":"boolean","default":false,"description":"Adds a 'Continue in Messenger' option to the thank-you page (Meta thank_you_page.enable_messenger), so the lead can carry on chatting with the Page. Set thankYouButtonType to MESSAGE_BUSINESS or P2B_MESSENGER to make the chat the primary button."},"isOptimizedForQuality":{"type":"boolean","description":"Set true for a higher-intent form (adds a review step before submit)."},"isPhoneSmsVerifyEnabled":{"type":"boolean","default":false,"description":"Requires the lead to verify their phone number over SMS before the form submits (Meta is_phone_sms_verify_enabled). Only meaningful on a form with a PHONE question. Meta can restrict this parameter to apps holding a capability: when it does, the create fails with a 422 naming platformSpecificData.isPhoneSmsVerifyEnabled, and the toggle then has to be set in Meta's form builder."},"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","format":"uri","description":"Direct public JPEG or PNG image URL, up to 5 MB. Uploaded as an unpublished Page photo and attached to the form. Redirects, Ad Image hashes and IDs are not supported."}}}}},{"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. Code 3 is Meta's generic app-capability error and does not name a field; when the request set isPhoneSmsVerifyEnabled, the response names that field as the one to drop first."}}}},"/v1/ads/lead-forms/{formId}":{"get":{"x-resource-group":"ads","operationId":"getLeadForm","tags":["Lead Gen"],"x-platforms":["meta","linkedin"],"summary":"Get a lead form","description":"Returns the full form, including the thank-you page, so a form can be diffed against what was created. Meta forms are scoped to the Page the accountId manages: a form on any other Page is a 404, never a read.\n","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 Meta ads, facebook or linkedin ads account id (selects the platform). A Meta ads connection resolves its Page through the Facebook account linked to the same profile."},{"name":"fields","in":"query","schema":{"type":"string","maxLength":2000},"description":"Meta only. A Graph field selection passed through verbatim to GET /{form-id}, replacing the default projection, so fields Meta adds later are reachable without an API change. Field names, commas and {} expansion only; anything else (Graph field modifiers such as .limit(), or characters that could open another query parameter) is a 400. Ownership of the form is verified before the selection runs, so this cannot reach any Page but the one accountId manages. Unknown field names are rejected by Meta as a 400.\n","example":"name,thank_you_page{title,body,button_type,website_url}"}],"responses":{"200":{"description":"Form metadata. Meta forms follow MetaLeadForm; LinkedIn forms return LinkedIn's own adForm shape.","content":{"application/json":{"schema":{"type":"object","properties":{"status":{"type":"string","example":"success"},"form":{"oneOf":[{"$ref":"#/components/schemas/MetaLeadForm"},{"type":"object"}]}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"No lead form with that id on the Page this account manages."}}},"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). Meta forms must belong to the Page the accountId manages.","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 Meta ads, facebook or linkedin ads account id (selects the platform). A Meta ads connection resolves its Page through the Facebook account linked to the same profile."}],"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"},"404":{"description":"No lead form with that id on the Page this account manages."}}}},"/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. Accepts a Facebook account or a metaads business-login account with leads_retrieval access to the form; the latter uses its system-user token without a posting parent.\n","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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}}},"/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.\n","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\ncreatives aren't hosted at a public URL. Returns the image `hash` (Meta's identifier for\nthe asset) and the Meta-hosted `url`, which can be used directly as `imageUrl` on the\ncreate 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); its platform decides where the campaign is created."},"adAccountId":{"type":"string","description":"Platform ad account id (Meta act_<n>, Google customer id, LinkedIn account id, ...)."},"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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Only supported on Meta (facebook/instagram)"},"502":{"description":"Meta accepted the request then failed to produce the media (upload session, chunk transfer, processing timeout, or a response with no image hash). Inspect `platformError.reason`."}}},"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.\nThe default projection covers hash, url, name, dimensions and status; `fields` is a\nraw-passthrough override. Any `hash` here is reusable wherever Meta accepts\n`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","example":"hash,url,name,width,height,status"},"description":"Comma-separated Graph field override. Supports nested {} projections and Graph field modifiers, so a nested edge can be paged explicitly: without a .limit() modifier the expansion runs at the Meta default page size and the tail is dropped silently."},{"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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Only supported on Meta (facebook/instagram)"}}}},"/v1/ads/videos":{"post":{"x-resource-group":"ads","operationId":"uploadAdVideo","tags":["Ad Creatives"],"x-platforms":["meta"],"summary":"Upload an ad video","description":"Standalone ad-video upload (parallel to POST /v1/ads/images), so a video creative can\nbe rendered via POST /v1/ads/preview or attached via `video.id` on POST /v1/ads/create\nbefore an ad exists.\n\nAccepts either an https `videoUrl` we download server-side (SSRF-guarded) or raw\n`videoBase64` bytes; exactly one is required. `videoBase64` is capped by Vercel's body\nlimit, around 4.5 MB payload in practice, so larger videos must come via `videoUrl`.\n\nReturns the Meta `video.id` (reusable wherever `video.id` is accepted) plus Meta's\nauto-generated poster URL when available. The endpoint waits until Meta reports the\nvideo ready (chunked upload + transcode can take minutes; the handler runs up to\n800 s).","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","adAccountId"],"properties":{"accountId":{"type":"string","description":"Zernio SocialAccount id (posting or ads variant); its platform decides where the campaign is created."},"adAccountId":{"type":"string","description":"Platform ad account id (Meta act_<n>, Google customer id, LinkedIn account id, ...)."},"videoUrl":{"type":"string","description":"Public https URL of the video; downloaded server-side (SSRF-guarded) before chunked upload. Provide exactly one of videoUrl or videoBase64."},"videoBase64":{"type":"string","description":"Raw base64 video bytes, or a full data URL (the data:video/...;base64, prefix is stripped). Capped by Vercel's body limit (~4.5 MB payload). Provide exactly one of videoUrl or videoBase64."},"filename":{"type":"string","description":"Optional filename shown alongside the upload session. Applied only when uploading via videoBase64."}}}}}},"responses":{"201":{"description":"Video uploaded and ready","content":{"application/json":{"schema":{"type":"object","properties":{"adAccountId":{"type":"string"},"video":{"type":"object","properties":{"id":{"type":"string","description":"Meta video id, reusable as video.id on POST /v1/ads/create and inside POST /v1/ads/preview creativeSpec."},"thumbnailUrl":{"type":["string","null"],"description":"Meta-hosted poster URL if available; null when Meta has not produced a poster yet."}}}}}}}},"400":{"description":"Invalid input, or Meta rejected the upload"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Only supported on Meta (facebook/instagram)"},"502":{"description":"Meta accepted the request then failed to produce the media (upload session, chunk transfer, processing timeout, or a response with no video id). Inspect `platformError.reason`."}}},"get":{"x-resource-group":"ads","operationId":"listAdVideos","tags":["Ad Creatives"],"x-platforms":["meta"],"summary":"Ad video library","description":"Lists the ad account's video library (Meta's `/act_X/advideos`), rows returned verbatim.\nThe default projection covers id, title, status, poster frames, length and `source` (the\nplayable MP4); `fields` is a raw-passthrough override. Any `id` here is reusable as\n`video.id` on the create endpoints, so N ads that differ only in copy share one upload.\n\n`source` lets you PLAY a video before picking it, which a poster frame alone can't settle\nwhen several videos share a first frame. It is a signed CDN URL that EXPIRES, so treat it\nas good for preview at selection time only. Never persist it; re-list to get a fresh one.\n\nThis is the only way to reach a video uploaded OUTSIDE Zernio (Ads Manager, another\ntool); videos we uploaded also come back as `creative.videoId` on GET /v1/ads.\n\nMeta transcodes asynchronously, so a row is only usable once `status.video_status`\nreads `ready`. Upload a new video via POST /v1/ads/videos, or inline via `video.url`\non POST /v1/ads/create.","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","example":"id,title,status,thumbnails,length"},"description":"Comma-separated Graph field override. Supports nested {} projections and Graph field modifiers, so a nested edge can be paged explicitly: without a .limit() modifier the expansion runs at the Meta default page size and the tail is dropped silently."},{"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 videos (raw Meta shape)","content":{"application/json":{"schema":{"type":"object","properties":{"adAccountId":{"type":"string"},"data":{"type":"array","items":{"type":"object","description":"Raw Meta ad video row (id, title, status, picture, thumbnails, length, source)."}},"paging":{"type":"object","properties":{"after":{"type":["string","null"],"description":"Cursor for the next page; null when exhausted."}}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Only supported on Meta (facebook/instagram)"}}}},"/v1/ads/videos/{videoId}":{"delete":{"x-resource-group":"ads","operationId":"deleteAdVideo","tags":["Ad Creatives"],"x-platforms":["meta"],"summary":"Delete an ad video","description":"Removes a video from the ad account's video library. Meta's canonical\n`DELETE /{video_id}` fails with code 10 / subcode 1363055 for videos uploaded via\n`/act_X/advideos` even with `ads_management`; this endpoint uses the working\naccount-scoped shape `DELETE /act_X/advideos?video_id=<id>` and returns Meta's\n`{success: true}` verbatim. Deleting a video that lives in a different ad account,\nor that Meta has already removed, returns Meta's error verbatim as a 4xx.","security":[{"bearerAuth":[]}],"parameters":[{"name":"videoId","in":"path","required":true,"schema":{"type":"string"},"description":"Meta ad video id (numeric)."},{"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>) that owns the video."}],"responses":{"200":{"description":"Video deleted","content":{"application/json":{"schema":{"type":"object","properties":{"adAccountId":{"type":"string"},"videoId":{"type":"string"},"success":{"type":"boolean"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\nbackward compatibility, it returns the legacy `{ interests: [...] }` shape rather\nthan the normalized `{ results: [...] }`. New integrations should use\n`GET /v1/ads/targeting/search` with `dimension=interest`.\n","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":"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"}}}}}}}}},"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."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}}},"/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\nthe `TargetingSpec` (`countries`/`regions`/`cities`/`zips`/`metros` geo keys, and\n`interests`/`behaviors` entity ids) on `POST /v1/ads/create`,\n`POST /v1/ads/targeting/reach-estimate`, and `saved_targeting` audiences.\n\nThe `dimension` param selects what is searched:\n\n- `geo`: locations, further scoped by `geoType`\n- `interest`\n- `behavior`\n- `income`\n- `language`: Google-only\n- `workPosition`, `workEmployer`, `workIndustry`: the Meta-only work demographics, whose\n  ids feed `TargetingSpec.workPositions`/`workEmployers`/`workIndustries`\n- `industry`, `jobFunction`, `seniority`, `companySize`: the LinkedIn-only B2B facets, whose\n  URNs feed `TargetingSpec.industries`/`jobFunctions`/`seniorities`/`companySizes`\n\nAvailability of each dimension varies by platform (e.g. behaviours are Meta/TikTok only).\nWork industries are a fixed ~30-entry Meta catalog with no server-side query,\nso `workIndustry` matching, ranking and `limit` happen in Zernio. `language`\nis likewise a fixed, checked-in table of Google's targetable\n`language_constant` rows (id, ISO code, name) matched by name or code, capped\nat 20, with no network call; its ids feed `TargetingSpec.languages`.\n\nResults are normalized across platforms into a single shape, so the same client code\nconsumes Meta, TikTok, LinkedIn, X, Pinterest, and Google results.\n\nTikTok geo searches return every matching level in one list (`type` is\n`country`, `region`, `city`, `district`, or `metro` for DMA areas), and\n`geoType` is not applied. Results are scoped to the advertiser's targetable\nmarkets, and every id is usable in `regions`/`cities`/`metros` keys on\n`POST /v1/ads/create`.\n\nLinkedIn geo searches also return every matching level in one list, and\nneither `geoType` nor `countryCode` is applied: LinkedIn's typeahead only\nreturns a name and a URN per result, with no level or country field to\nfilter on. Every result has `type` set to `location`, and its id is a\n`urn:li:geo:*` URN usable as a `regions[].key` on `POST /v1/ads/create`,\n`POST /v1/ads/boost` and `POST /v1/ads/targeting/reach-estimate`.\n\nLinkedIn B2B searches (`industry`, `jobFunction`, `seniority`, `companySize`) return the\nfull URN to pass straight back, so no URN id fragment has to be assembled by hand:\n`urn:li:industry:4`, `urn:li:function:8`, `urn:li:seniority:6`,\n`urn:li:staffCountRange:(51,200)`. Only `industry` is a server-side name search\n(LinkedIn's typeahead finder). LinkedIn exposes no typeahead for job functions,\nseniorities and company sizes, so Zernio fetches each whole table (26, 10 and 9 entries),\ncaches it, and does the matching, ranking and `limit` cutoff itself. Those three never\ncarry `audienceSize`, and `countryCode` and `geoType` are not applied to any of the four.\n\nGoogle geo searches resolve against Google's geoTargetConstants and return\nevery matching level in one list; `geoType` is not applied (Google's\n`target_type` is an open taxonomy that does not map one-to-one onto the\n`geoType` enum), so filter client-side on the returned `type` (`country`,\n`region`, `city`, `zip`, `metro`, or the lowercased Google target type for\nrarer levels). `countryCode` scopes the search to one country. Each id is\nGoogle's numeric criterion id, usable as a `regions`/`cities`/`zips`/`metros`\n`key` on `POST /v1/ads/create`. Google city radius is not supported (pass a\n`customLocations` lat/lng pin for a radius); country targeting also accepts\nplain ISO codes via `countries` with no search call.\n\nPinterest resolves against three whole-catalog endpoints (interests, locations,\nregions) with no server-side query or pagination, so matching, ranking and the\n`limit` cutoff all happen in Zernio; the catalog is independent of any ad account\nand results never carry `audienceSize`. Names come back localized to the connected\nPinterest account's language (there is no way to force a locale), so match against\nwhatever language that account returns.\n\n`geoType` routes to a different Pinterest catalog:\n\n- `country` and `metro_area` read the locations catalog (`type` is `country` or `metro`)\n- `region` reads the regions catalog (`type` is `region`, its id a `regions[].key` on\n  `POST /v1/ads/create`)\n- `all` and the default `city` merge both catalogs with honest per-entry `type`s, since\n  Pinterest has no city-level catalog and `city` is an alias for `all`, not a literal\n  city search\n- `zip`, `subcity`, `neighborhood`, `place` and `geo_market` return a 400: Pinterest\n  exposes no postal-code catalog, pass postal codes directly as\n  `targeting.zips: [{ key }]` on `POST /v1/ads/create`\n\nFor geo queries, `q` should contain only the locality name (e.g. `\"Amsterdam\"`,\nnot `\"Amsterdam, NL\"`). Use `countryCode` to disambiguate.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"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","language","workPosition","workEmployer","workIndustry","industry","jobFunction","seniority","companySize"],"default":"interest"},"description":"What to search. `geo` resolves locations (scope further with `geoType`), `interest`/`behavior` resolve audience entities, `income` resolves income-tier options, `language` resolves Google's targetable language_constant table (Google only), `workPosition`/`workEmployer`/`workIndustry` resolve Meta work demographics, `industry`/`jobFunction`/`seniority`/`companySize` resolve LinkedIn B2B facets (LinkedIn only). Defaults to `interest` for backward compatibility with the deprecated /v1/ads/interests alias."},{"name":"geoType","in":"query","required":false,"schema":{"type":"string","enum":["all","country","country_group","region","city","subcity","neighborhood","place","zip","metro_area","geo_market"],"default":"city"},"description":"Only used when `dimension=geo`. The kind of location to resolve. `all` searches every type in one relevance-ranked call. 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, location, interest, behavior, income, industry, jobFunction, seniority, companySize)."},"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":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}}},"/v1/ads/library":{"get":{"x-resource-group":"ads","operationId":"searchAdLibrary","tags":["Ad Library"],"x-platforms":["meta","linkedin"],"summary":"Search the public Ad Library","description":"Competitor and market research over the public ad archives. Meta's Ad Library\n(`GET /ads_archive`) is searched with Zernio's own developer access, so `platform=meta` needs\nno connected account at all. LinkedIn's Ad Library (`GET /rest/adLibrary`) runs on a connected\n`linkedin` / `linkedinads` account, passed as `accountId`. Passing a Meta account as `accountId`\nalso selects Meta. Rows are returned in the platform's raw shape under `data`; `paging.after`\nis an opaque cursor on both (`null` when exhausted).\n\n**Meta coverage.** Political and social-issue ads are searchable worldwide. Every other ad is\nin the archive only if it was delivered to the EU or UK within the last year, so a US-only\ncommercial advertiser is invisible. Spend, impressions and demographics are political-only\nfields and are left out of the default projection; request them via `fields`. All customers\nshare Zernio's Meta quota, so a `429` means back off for a minute.\n\n**LinkedIn coverage.** Ads served after June 1 2023, worldwide, kept for a year after their\nlast impression. EU-delivered ads carry impression ranges and the disclosed targeting facets.\nPages are capped at 25 ads (`limit` > 25 is a 400); `after` is the next offset.\n\nWhich params apply: `q`, `countries`, `since`, `until`, `limit`, `after` on both; `pageIds`,\n`adType`, `status`, `platforms`, `mediaType`, `languages`, `searchType`, `fields` are Meta-only;\n`advertiser` is LinkedIn-only. Passing a param the account's platform does not support is a 400\nnaming the param.","security":[{"bearerAuth":[]}],"parameters":[{"name":"platform","in":"query","schema":{"type":"string","enum":["meta","linkedin"]},"description":"Which archive to search. `meta` needs no accountId. Required unless accountId is given."},{"name":"accountId","in":"query","schema":{"type":"string"},"description":"Zernio SocialAccount id. Required for LinkedIn (linkedin / linkedinads: its token searches). Optional for Meta, where any facebook / instagram / metaads account only selects the platform."},{"name":"q","in":"query","schema":{"type":"string","maxLength":100},"description":"Keyword search. Meta does not translate it, so write it in the ads' language. Required unless pageIds (Meta) or advertiser (LinkedIn) is given."},{"name":"pageIds","in":"query","schema":{"type":"string"},"description":"Meta only. Comma-separated Facebook Page ids (max 10) whose ads to list."},{"name":"advertiser","in":"query","schema":{"type":"string","maxLength":100},"description":"LinkedIn only. Advertiser (Page) name to search."},{"name":"countries","in":"query","schema":{"type":"string"},"description":"Comma-separated ISO 3166-1 alpha-2 codes the ads reached. Meta defaults to ALL (an explicit ALL is Meta-only); LinkedIn searches every market when omitted."},{"name":"adType","in":"query","schema":{"type":"string","enum":["ALL","POLITICAL_AND_ISSUE_ADS","HOUSING_ADS","EMPLOYMENT_ADS","FINANCIAL_PRODUCTS_AND_SERVICES_ADS"],"default":"ALL"},"description":"Meta only."},{"name":"status","in":"query","schema":{"type":"string","enum":["ACTIVE","INACTIVE","ALL"],"default":"ACTIVE"},"description":"Meta only. ACTIVE = eligible for delivery right now."},{"name":"platforms","in":"query","schema":{"type":"string"},"description":"Meta only. Comma-separated publisher platforms: FACEBOOK, INSTAGRAM, AUDIENCE_NETWORK, MESSENGER, WHATSAPP, OCULUS, THREADS, STREAMING_SERVICES."},{"name":"mediaType","in":"query","schema":{"type":"string","enum":["ALL","IMAGE","MEME","VIDEO","NONE"]},"description":"Meta only."},{"name":"languages","in":"query","schema":{"type":"string"},"description":"Meta only. Comma-separated ISO 639-1 codes of the ad text."},{"name":"since","in":"query","schema":{"type":"string","format":"date"},"description":"Earliest delivery date (YYYY-MM-DD)."},{"name":"until","in":"query","schema":{"type":"string","format":"date"},"description":"Latest delivery date (YYYY-MM-DD)."},{"name":"searchType","in":"query","schema":{"type":"string","enum":["KEYWORD_UNORDERED","KEYWORD_EXACT_PHRASE"],"default":"KEYWORD_UNORDERED"},"description":"Meta only. Whether q matches words in any order or as an exact phrase (comma-separate phrases to match all of them)."},{"name":"fields","in":"query","schema":{"type":"string","example":"id,page_name,ad_delivery_start_time,ad_creative_bodies"},"description":"Meta only. Comma-separated Graph field override. Supports nested {} projections and Graph field modifiers, so a nested edge can be paged explicitly: without a .limit() modifier the expansion runs at the Meta default page size and the tail is dropped silently."},{"name":"limit","in":"query","schema":{"type":"integer","minimum":1,"maximum":100,"default":25},"description":"Rows per page. LinkedIn accepts at most 25."},{"name":"after","in":"query","schema":{"type":"string"},"description":"paging.after of the previous page."}],"responses":{"200":{"description":"Archived ads (raw platform shape)","content":{"application/json":{"schema":{"type":"object","required":["platform","data","paging"],"properties":{"platform":{"type":"string","enum":["meta","linkedin"]},"data":{"type":"array","items":{"type":"object","description":"Meta: an ArchivedAd (id, page_id, page_name, ad_creation_time, ad_delivery_start_time, ad_delivery_stop_time, ad_creative_bodies[], ad_creative_link_titles[], ad_creative_link_descriptions[], ad_creative_link_captions[], ad_snapshot_url, publisher_platforms[], languages[], plus eu_total_reach, beneficiary_payers, target_ages, target_gender, target_locations, age_country_gender_reach_breakdown, total_reach_by_location on EU/UK ads). LinkedIn: { adUrl, isRestricted, details: { type, advertiser: { advertiserName, advertiserUrl, adPayer }, adStatistics: { firstImpressionAt, latestImpressionAt, totalImpressions: { from, to }, impressionsDistributionByCountry[] }, adTargeting: [{ facetName, includedSegments[], excludedSegments[] }] } } (adStatistics and adTargeting only on EU-delivered ads)."}},"paging":{"type":"object","properties":{"after":{"type":["string","null"],"description":"Cursor for the next page; null when exhausted."},"total":{"type":"integer","description":"LinkedIn only. Total matching ads."}}}}},"examples":{"meta":{"summary":"Meta, an EU-delivered ad","value":{"platform":"meta","data":[{"id":"1234567890","page_id":"811889972008357","page_name":"Zernio","ad_creation_time":"2026-08-02","ad_delivery_start_time":"2026-08-03","ad_creative_bodies":["Schedule posts and run ads from one API."],"ad_creative_link_titles":["Zernio for developers"],"ad_snapshot_url":"https://www.facebook.com/ads/archive/render_ad/?id=1234567890&access_token=...","publisher_platforms":["facebook","instagram"],"languages":["en"],"eu_total_reach":18400,"target_ages":["25","54"],"target_gender":"All"}],"paging":{"after":"QVFIUm..."}}},"linkedin":{"summary":"LinkedIn, an EU-delivered ad","value":{"platform":"linkedin","data":[{"isRestricted":false,"adUrl":"https://www.linkedin.com/ad-library/detail/1521996423","details":{"type":"SPONSORED_STATUS_UPDATE","advertiser":{"advertiserName":"CEE Microsoft Dynamics Partners","advertiserUrl":"https://www.linkedin.com/company/71491884","adPayer":"UAB Companial"},"adStatistics":{"firstImpressionAt":1788623666998,"latestImpressionAt":1788780782520,"totalImpressions":{"from":5000,"to":10000},"impressionsDistributionByCountry":[]},"adTargeting":[{"facetName":"Language","includedSegments":["polski"],"excludedSegments":[]},{"facetName":"Location","includedSegments":["Polska"],"excludedSegments":[]}]}}],"paging":{"after":"25","total":27147}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access required (legacy plans need the Ads add-on; included on usage-based plans), or `payment_required`: the billing owner has no payment method on file and no legacy paid plan. Searches are free; the card keeps the shared archive quota for real accounts."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Only supported on Meta and LinkedIn accounts"},"503":{"description":"Meta's Ad Library is unavailable on Zernio's side (`PLATFORM_DISABLED`); LinkedIn searches are unaffected."}}}},"/v1/ads/targeting/reach-estimate":{"post":{"x-resource-group":"ads","operationId":"estimateAdReach","tags":["Ad Targeting"],"x-platforms":["meta","linkedin","pinterest","x"],"summary":"Estimate audience reach","description":"Returns a normalized pre-flight audience-size estimate for a targeting spec,\nbefore any campaign is created. Backed by each platform's native reach API\n(Meta `delivery_estimate`, LinkedIn `audienceCounts`, X `audience_summary`,\nPinterest `audience_sizing`).\n\nPlatforms without a usable pre-flight reach API (Google Search/Display, TikTok)\nreturn `available: false` with no bounds, so clients can hide or grey out the\nestimate rather than treat the absence as an error.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","adAccountId","spec"],"properties":{"accountId":{"type":"string","description":"Zernio 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\nown vocabulary, e.g. Meta `REACH`, `LINK_CLICKS`, `OFFSITE_CONVERSIONS`).\nSome platforms vary the estimate by goal; omit to use the platform default.\n"}}}}}},"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/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}}},"/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\nspec, plus the daily-budget bounds LinkedIn will accept. Use it before\ncreating a campaign to pick a bid inside the allowed range and warn the\nuser if their daily budget is below the minimum. Wraps LinkedIn's\n`adBudgetPricing` finder.\n\nNon-LinkedIn accounts return `available: false` so clients can hide the\npricing UI without treating it as a failure.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","adAccountId","spec"],"properties":{"accountId":{"type":"string","description":"Zernio 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/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}}},"/v1/ads/targeting/supply-forecast":{"post":{"x-resource-group":"ads","operationId":"getLinkedInSupplyForecast","tags":["Ad Targeting"],"x-platforms":["linkedin"],"summary":"Forecast ad delivery","description":"LinkedIn-only. Forecasted impressions, clicks, spend and ~20 other\nmetrics for a targeting spec over a time range. Wraps LinkedIn's\n`adSupplyForecasts` finder.\n\nEach returned series carries a `metricType` (IMPRESSION, CLICK, SPENDING,\nMAX_POTENTIAL_BUDGET, COST_PER_MILLION_IMPRESSIONS, ...) and a\n`granularity` (DAILY, SEVEN_DAY, THIRTY_DAY, CUSTOM). LinkedIn caps the\ndaily spending forecast at 1.2x the daily budget and returns 0 once the\ntotal budget is exhausted.\n\nNon-LinkedIn accounts return `available: false`.\n","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/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}}},"/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 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."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}}},"/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 id, not the parent catalog id, as `promotedObject.productSetId` on POST /v1/ads/create with `goal: catalog_sales`. Creation verifies set visibility and returns 400 for a catalog id or an inaccessible set.","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 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."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}}},"/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.","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"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"],"description":"The Zernio audience id. Pass this as audienceId on GET /v1/ads/audiences/{audienceId} and the companies/users upload endpoints. Null when the audience was not created through Zernio."},"accountId":{"type":"string","description":"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"}}}}}}}}},"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."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}},"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;\n`website` and `lookalike` are Meta-only; `company_list`, `engagement` and `website_retargeting` are LinkedIn-only.\n`saved_targeting` stores a reusable TargetingSpec (no member upload, no adAccountId) that you reference later via\n`savedTargetingId` on `POST /v1/ads/create`.\n\nHow the audience gets filled depends on the type:\n\n- `customer_list` is created empty. Add members with `POST /v1/ads/audiences/{audienceId}/users`.\n  On TikTok and Pinterest the audience is provisioned lazily on that first upload (until then its status is `pending`).\n- `company_list` is filled AT CREATION from the `companies` array below, which is required. To change the list\n  afterwards send the new full list to `POST /v1/ads/audiences/{audienceId}/companies` (a replace, not a merge).\n  The `/users` endpoint rejects these audiences with a 422.\n- `website`, `website_retargeting`, `engagement`, `meta_engagement` and `lookalike` fill themselves from the pixel,\n  engagement source or seed audience you point them at. They take no member upload at all.\n\nCreate is not idempotent, never auto-retry.\n","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).\nEach rule is a URL pattern; a member who visits any\nmatching page enters the segment. Needs the LinkedIn\nInsight Tag installed on the customer's site; the\nsegment only starts filling once the tag reports visits.\n\nThe response's `platformAudienceId` is the LinkedIn\nadSegment id, valid for downstream use.\nThese segments appear in GET /v1/ads/audiences with \n`type: website_retargeting` once LinkedIn has finished building them.\n","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\nmembers engaged with: a video/leadgen/single-image ad\ncampaign, a Company Page or an Event page.\n"},"trigger":{"type":"string","description":"Required for engagement audiences. The action, validated\nby LinkedIn against `sourceType`. Common values:\nVIDEO_ADS FIRST_QUARTILE / MIDPOINT / THIRD_QUARTILE /\nFULL_COMPLETE; LEAD_GEN_FORMS VIEW_FORM /\nLEAD_FORM_SUBMIT; ORGANIZATION_PAGES VIEW / CTA_CLICK;\nEVENT_PAGES RSVPED / VIDEO_VIEWED / ENGAGEMENT / CLICK.\n"},"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\nad source types, organization URNs for pages and events.\nLinkedIn creates one rule per source, all sharing the\nsame trigger and lookbackDays.\n","items":{"type":"string"}},"companies":{"type":"array","minItems":1,"maxItems":300000,"description":"Required for company_list audiences (LinkedIn only): plain-text\ncompany rows for account targeting. Each row needs at least one\nidentifier. Not hashed, LinkedIn matches these against its own\ncompany graph. LinkedIn recommends 1,000+ companies for a usable\nmatch rate and takes up to 48h to process the list. Replace the\nlist later with POST /v1/ads/audiences/{audienceId}/companies.\n","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\nengaged with. `page` = a Facebook Page, `instagram` = an IG\nprofessional account, `video` = a video.\n"},"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\n(page → page_engaged, instagram → ig_business_profile_all,\nvideo → video_watched). Ignored when `rule` is provided.\n"},"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.2,"description":"Required for lookalike audiences"},"urlContains":{"type":"string","description":"website only. Narrows the audience from all visitors to visitors of\nURLs containing this substring. Ignored when `rule` is supplied.\n"},"rule":{"type":"object","description":"Optional raw Meta rule, replacing the one we build. Omit it for all\nvisitors of `pixelId`, or use `urlContains` for the common page-match\ncase.\n\nFor `website` this is Meta's Flexible Audience Rule and is VALIDATED\nbefore we call Meta: every entry in `inclusions.rules` (and\n`exclusions.rules`) must carry `event_sources`, `retention_seconds`\nAND `filter`. Meta rejects a rule missing any of the three with code\n100 / subcode 1713098 (\"Invalid rule JSON format\"), so a bad shape is\na 400 here instead. The pre-2018 flat shapes (`{url: ...}`,\n`{event: ...}`) are not accepted by Meta at all (subcode 1870029).\n\nExample, visitors of /checkout in the last 30 days:\n`{\"inclusions\":{\"operator\":\"or\",\"rules\":[{\"event_sources\":[{\"id\":\"<pixelId>\",\"type\":\"pixel\"}],\"retention_seconds\":2592000,\"filter\":{\"operator\":\"and\",\"filters\":[{\"field\":\"url\",\"operator\":\"i_contains\",\"value\":\"/checkout\"}]}}]}}`\n\nNote Meta DERIVES `retention_days` from `retention_seconds` and\nstores `event_sources[].id` as a number, so a rule read back will\nnot be byte-identical to the one you sent.\n\nFor `meta_engagement` the rule is forwarded verbatim and NOT\nvalidated: that type has two dialects (the `video` source uses a\nlegacy flat array), so no single schema covers both.\n"},"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":"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."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}}},"/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"},"description":"The Zernio audience id (the id field of GET /v1/ads/audiences), not the platform segment id."}],"responses":{"200":{"description":"Audience details","content":{"application/json":{"schema":{"type":"object","properties":{"audience":{"type":"object"},"platformData":{"type":["object","null"],"description":"Fresh data from the platform 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`\n(full replacement, no merge, Zernio-only, no platform call). Platform audiences\n(uploaded/website/lookalike) accept `name` and `description` only, updated on the\nplatform first and then mirrored locally; their rules are immutable, so `spec` returns\n400 for them. Platform audience updates are Meta-only for now (other platforms return\n501). Ads already created from a saved_targeting audience are unaffected, they snapshot\nthe targeting at creation.\n","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.\nEmail is used on every platform; phone is used on Meta only (other platforms ignore it). On TikTok and Pinterest,\nthe first upload also provisions the audience (deferred create). LinkedIn uploads are full-replace. Max 10,000 users per request.\n\ncustomer_list only. A LinkedIn `company_list` audience takes company rows, not people: send those to\n`POST /v1/ads/audiences/{audienceId}/companies`. This endpoint 422s for every other audience type.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"audienceId","in":"path","required":true,"schema":{"type":"string"},"description":"The Zernio audience id (the id field of GET /v1/ads/audiences), not the platform segment id."}],"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/audiences/{audienceId}/companies":{"post":{"x-resource-group":"ads","operationId":"replaceAdAudienceCompanies","tags":["Ad Audiences"],"x-platforms":["linkedin"],"summary":"Replace audience companies","description":"Upload the company rows of a LinkedIn `company_list` audience (account-based marketing).\nLinkedIn-only, every other platform returns 422.\n\nA LinkedIn audience segment holds exactly one uploaded list, so the list you send here\nREPLACES the segment's list instead of being appended to it: always send the full set of\ncompanies. LinkedIn returns only the identifier of the uploaded file, never its rows, so the\nmerge cannot be done for you, keep the source list on your side.\n\nHow the matching behaves:\n\n- Rows are plain text (not hashed), matched against LinkedIn's own company graph.\n- Matching is asynchronous: LinkedIn takes up to 48h for a new audience and up to 24h for a\n  later update, and the audience stays `processing` meanwhile.\n- LinkedIn does not document how quickly companies dropped from the list stop being targeted,\n  so treat removals as eventual rather than immediate.\n- LinkedIn recommends at least 1,000 companies for a usable match rate, and caps a list at\n  300,000.\n\nThe initial list is sent with `companies` on `POST /v1/ads/audiences`; this endpoint is for\nevery change after that.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"audienceId","in":"path","required":true,"schema":{"type":"string"},"description":"The Zernio audience id (the id field of GET /v1/ads/audiences), not the platform segment id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["companies"],"properties":{"companies":{"type":"array","minItems":1,"maxItems":300000,"description":"The complete company list. Each row needs at least one of name, domain, website or linkedinPageUrl.","items":{"type":"object","additionalProperties":false,"properties":{"name":{"type":"string","maxLength":255},"domain":{"type":"string","maxLength":100},"website":{"type":"string","maxLength":100},"linkedinPageUrl":{"type":"string"}}}}}}}}},"responses":{"200":{"description":"Companies uploaded","content":{"application/json":{"schema":{"type":"object","properties":{"message":{"type":"string"},"numReceived":{"type":"integer","description":"Rows sent to LinkedIn. Matching happens asynchronously, so this is not the matched company count."}}}}}},"400":{"description":"Invalid input (malformed audienceId, empty companies array, a row with no identifier)"},"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 company_list type, is not on LinkedIn, 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\npixel/dataset, live from Meta's Dataset Quality API. Web events only (a\nMeta limitation). Meta-only; other platforms return 405. Requires the Ads add-on.\n","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"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Platform does not expose Event Match Quality (non-Meta)."},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}}},"/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.\nPlatform is inferred from the provided `accountId`. Requires the Ads add-on.\n\nSupported platforms:\n\n- Meta (`metaads`) via Graph API\n- Google Ads (`googleads`) via Data Manager API `ingestEvents`\n- LinkedIn (`linkedinads`) via `/rest/conversionEvents`\n- TikTok (`tiktokads`) via the Offline Events API `/offline/batch/` (OFFLINE conversions only)\n- OpenAI Ads (`openaiads`) via its Conversions API (a separate host, `bzr.openai.com`)\n\n`destinationId` semantics differ per platform:\n\n- Meta: pixel (dataset) ID, e.g. `123456789012345`\n- Google: conversion action resource name, e.g. `customers/1234567890/conversionActions/987654321`\n- LinkedIn: conversion rule ID or URN, e.g. `104012` or `urn:lla:llaPartnerConversion:104012`\n- TikTok: Offline Event Set ID, e.g. `7057103914977558530`\n- OpenAI Ads: pixel wire id (numeric `pixel_id`, distinct from the internal pixel id), as returned by `GET /v1/accounts/{accountId}/conversion-destinations`\n\nTikTok notes: this path sends OFFLINE conversions (in-store / CRM / call-center), not web-pixel\nevents. Each event must carry an email or phone (TikTok requires at least one). The connected\nTikTok ads account must have granted the Offline Events permission; older grants must reconnect.\n\nOpenAI Ads notes: requires a tracking tag (pixel) to already exist on the account. Returns 422\nwith code `TRACKING_TAG_REQUIRED` if `POST /v1/accounts/{accountId}/tracking-tags` hasn't been\ncalled yet.\n\nCallers can list valid destinations via `GET /v1/accounts/{accountId}/conversion-destinations`.\n\nAll PII (email, phone, names, external IDs) is hashed with SHA-256 server-side per each\nplatform's normalization spec, including Google's Gmail-specific dot/plus-suffix stripping.\nSend plaintext. LinkedIn `externalIds` are passed through as plaintext per LinkedIn's spec;\nonly emails and phones are hashed.\n\nFor LinkedIn, the connected account must have been authorized after the Conversions API\nrollout (i.e. the OAuth grant must include `rw_conversions`). Older accounts must reconnect.\n\nBatching is handled automatically. Meta caps at 1000 events per request and rejects the\nentire batch if any event is malformed. Google caps at 2000. LinkedIn caps at 5000 and is\nalso all-or-nothing per chunk. OpenAI Ads caps at 1000 per request; larger submissions are\nsplit into 1000-event chunks, each all-or-nothing (a malformed event fails every event in\nthat chunk, not the whole request).\n\nDedup: pass a stable `eventId` on every event. Meta and LinkedIn use it to dedupe against\nbrowser-side pixel/Insight Tag events; Google maps it to `transactionId`.\n\nPer-platform `eventName` semantics:\n\n- Meta: free-form. Standard names (Purchase, Lead, ...) match Meta's built-in events; custom strings are accepted.\n- 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.\n- 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.\n- 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.\n","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\nID. For Google, the conversion action resource name. For\nLinkedIn, the conversion rule ID or full\n`urn:lla:llaPartnerConversion:{id}` URN. For OpenAI Ads, the\npixel wire id.\n"},"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\nevents under the Feb 2026 restrictions. On Meta, any\nDENIED flag enables Limited Data Use on every event in\nthe batch (data_processing_options [\"LDU\"] with\ngeolocation, country 0 / state 0); GRANTED or absent\nconsent sends events with Meta's default processing.\nIgnored by LinkedIn.\n","properties":{"adUserData":{"type":"string","enum":["GRANTED","DENIED"]},"adPersonalization":{"type":"string","enum":["GRANTED","DENIED"]}}}}}}}},"responses":{"200":{"description":"Events processed. Inspect `eventsFailed` and `failures[]` to detect\npartial failure. For Meta, a batch is all-or-nothing (either every\nevent in a chunk succeeds, or every event in the chunk is listed\nin failures). For Google, the API returns success/failure at the\nrequest level only. For OpenAI Ads, each 1000-event chunk is\nall-or-nothing, same as Meta.\n","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,\nrequestId for Google. Absent for LinkedIn (LinkedIn's\nconversionEvents endpoint does not surface a trace ID)\nand OpenAI Ads (no trace ID surfaced).\n"}}}}}},"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),\nOR (for LinkedIn) the connected account lacks the `rw_conversions` scope and must be reconnected.\n"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\nper token). Retry with backoff. Meta and Google have their own\nrate-limit semantics surfaced via platform-specific 4xx responses.\n"}}}},"/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`:\nretract them, restate their value, or enhance them with first-party data. Requires\nthe Ads add-on.\n\n**Google Ads only.** Google handles adjustments through the classic Google Ads API\n(`ConversionAdjustmentUploadService`); the Data Manager `ingestEvents` path used for\nsending conversions is ingest-only. Meta and LinkedIn have no equivalent, so this\nendpoint returns `405` for those platforms.\n\nAdjustment types:\n\n- `RETRACTION`: remove the conversion entirely (refund, chargeback, cancelled order, churn).\n- `RESTATEMENT`: change the conversion's value (upgrade / downgrade / partial refund). Send the corrected **total** value in `restatementValue` (not a delta).\n- `ENHANCEMENT`: attach first-party identifiers (hashed email / phone) to an existing conversion (enhanced conversions applied after the fact).\n\nIdentifying the original conversion (per adjustment):\n\n- `orderId`: the transaction ID you sent as `eventId` on the original conversion. Recommended, and **required** for `ENHANCEMENT`.\n- or `gclid` + `conversionTime`: the click ID and the original conversion's time (unix seconds). Not available for `ENHANCEMENT`.\n\n`destinationId` is the conversion action resource name, e.g.\n`customers/1234567890/conversionActions/987654321` (same value you send to\n`POST /v1/ads/conversions`). PII in `user` is hashed with SHA-256 server-side\n(Gmail-specific normalization included). Send plaintext.\n\nTimes are unix seconds; we convert to Google's required\n`yyyy-MM-dd HH:mm:ss+00:00` format. Up to 2000 adjustments per request; partial\nfailure is supported (inspect `adjustmentsFailed` / `failures[]`).\n","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\npartial failure (Google reports per-row errors via partial failure).\n","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":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Conversion adjustments are only available for Google Ads (the account's platform is not `googleads`)."},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}}},"/v1/ads/conversions/actions":{"get":{"x-resource-group":"ads","operationId":"listConversionActions","tags":["Conversions"],"x-platforms":["google"],"summary":"List conversion actions","description":"Lists Google Ads conversion actions on the resolved customer, all types by\ndefault. Each action's `tagSnippets` (global site tag + event snippet) is\nincluded when Google has them for that action's type, e.g. `WEBPAGE`.\nGoogle-only; other platforms return `501`. Requires the Ads add-on.\n\n`customerId` is optional: when omitted, it is resolved from the connection's\naccessible Google Ads customers, and the call fails with `400` when more than\none is accessible (pass `customerId` to disambiguate).\n\nThe list itself is cached for the quota window (1 hour fresh, up to 7 days\nlast-good; the cache key does not vary on `type`). The response carries\n`cachedAt` and `stale`, set when a quota-exhausted call falls back to the\nlast-good copy instead of a live read.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"SocialAccount _id (must be a googleads account)."},{"name":"customerId","in":"query","required":false,"schema":{"type":"string"},"description":"Google Ads customer id (digits only). Resolved automatically when the connection has exactly one accessible customer."},{"name":"type","in":"query","required":false,"schema":{"type":"string"},"description":"Filter by Google's ConversionActionType enum (e.g. WEBPAGE, UPLOAD_CLICKS)."}],"responses":{"200":{"description":"The resolved customer and its conversion actions.","content":{"application/json":{"schema":{"type":"object","properties":{"customerId":{"type":"string","description":"The Google Ads customer id the actions were read from."},"actions":{"type":"array","items":{"$ref":"#/components/schemas/ConversionAction"}},"cachedAt":{"type":["string","null"],"format":"date-time","description":"When this list was fetched from Google. Null when it was never served from cache."},"stale":{"type":"boolean","description":"True when Google's daily API quota was exhausted and this is the last successful fetch, not a live read."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access required (Ads add-on on legacy plans, included on usage-based plans)."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Conversion actions are only available for Google Ads (the account's platform is not `googleads`)."}}},"post":{"x-resource-group":"ads","operationId":"createConversionAction","tags":["Conversions"],"x-platforms":["google"],"summary":"Create website conversion action","description":"Creates a `WEBPAGE` conversion action (category `DEFAULT`) and returns it with\nits tag snippets, read back after creation since Google never returns them on\nthe create response itself. Invalidates the cached list `GET` on this resource\nwould otherwise keep serving. Google-only; other platforms return `501`.\nRequires the Ads add-on.\n","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","name","type"],"properties":{"accountId":{"type":"string","description":"SocialAccount ID. Must be a `googleads` account."},"customerId":{"type":"string","description":"Google Ads customer id (digits only). Resolved automatically when the connection has exactly one accessible customer."},"name":{"type":"string","minLength":1,"maxLength":100},"type":{"type":"string","enum":["WEBPAGE"],"description":"Only WEBPAGE is supported for creation today."},"defaultValue":{"type":"number","minimum":0,"description":"Default conversion value used when an event doesn't carry its own value."},"alwaysUseDefaultValue":{"type":"boolean","description":"When true, always use defaultValue and ignore any value sent with the event. Defaults to true when defaultValue is set."}}}}}},"responses":{"201":{"description":"The created conversion action, with its tag snippets.","content":{"application/json":{"schema":{"type":"object","properties":{"action":{"$ref":"#/components/schemas/ConversionAction"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access required (Ads add-on on legacy plans, included on usage-based plans)."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"501":{"description":"Conversion actions 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),\nconversion rules (LinkedIn), or pixels (OpenAI Ads) accessible to the\nconnected ads account. Use the returned `id` as `destinationId` when\nposting to `POST /v1/ads/conversions`.\n\nFor Google and LinkedIn, each destination's `type` reflects the\nconversion type (PURCHASE, LEAD, SIGN_UP, etc.), and the event type is\nlocked to the destination. For Meta and OpenAI Ads, `type` is absent:\npixels accept any event name per request.\n\nFor LinkedIn, destinations are returned across every sponsored ad\naccount the connected token can access; the `adAccountId` field on\neach destination identifies the parent ad account and is required for\nsubsequent CRUD calls (update, delete, associations, metrics).\n","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:\nconversion action resource name. LinkedIn:\nnumeric conversion rule ID. OpenAI Ads: pixel wire\nid.\n"},"name":{"type":"string"},"type":{"type":"string","description":"Present when the platform locks event type to the\ndestination (Google conversion actions, LinkedIn\nconversion rules).\n"},"status":{"type":"string","enum":["active","inactive"]},"adAccountId":{"type":"string","description":"Set by adapters whose destinations are scoped to a\nspecific ad account (LinkedIn). Pass back on\nsubsequent CRUD calls.\n"}}}}}}}}},"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),\nOR (for LinkedIn) the connected account lacks the `rw_conversions` scope and must be reconnected.\n"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\nLinkedIn (conversion rule) and Google Ads (conversion action). Meta\nand OpenAI Ads pixels are created via their own tracking-tags flow\ninstead (`POST /v1/accounts/{accountId}/tracking-tags`); this endpoint\nreturns 405 for both.\n\n**LinkedIn:** creation is NOT idempotent. A retry creates a second\ndestination. Deduplicate before retrying.\n\n**Google Ads:** calling with a name that already exists reuses the\nexisting conversion action transparently (the response is identical to\na fresh create). Calling with the same name but a different category\nreturns a typed `IDEMPOTENCY_CONFLICT` (409) rather than silently\nreturning the mismatched action.\n\n**LinkedIn:** the rule is created with `conversionMethod=CONVERSIONS_API`\nand (by default) auto-associated with all of the ad account's campaigns\nvia `autoAssociationType=ALL_CAMPAIGNS`. Pass `autoAssociationType: NONE`\nto opt out and manage associations explicitly via the associations\nendpoints below.\n\n365-day attribution windows are only valid for `SUBMIT_APPLICATION`,\n`PURCHASE`, `ADD_TO_CART`, `QUALIFIED_LEAD`, and `LEAD` rule types;\nthe API rejects other combinations locally.\n\n**Google Ads:** the conversion action is created with\n`type=UPLOAD_CLICKS` (required for API-uploaded offline conversions,\nimmutable after creation). The `type` field carries the Google\n`ConversionActionCategory` enum value, e.g. `PURCHASE`,\n`SUBSCRIBE_PAID`, `SIGNUP`, `IMPORTED_LEAD`, `BOOK_APPOINTMENT`.\nUnified standard event names (e.g. `Purchase`, `Subscribe`,\n`CompleteRegistration`, `Lead`, `Schedule`) are resolved to their\nGoogle category equivalents automatically. The action defaults to\nsecondary (non-primary) to avoid immediately steering Smart Bidding;\npass `primaryForGoal: true` to opt in.\n","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\nfull `urn:li:sponsoredAccount:{id}` URN. For Google: numeric\ncustomer ID (e.g. \"1234567890\") or `customers/{id}` form.\n"},"name":{"type":"string","maxLength":255},"type":{"type":"string","description":"Conversion type. For LinkedIn: a unified standard event name\n(e.g. \"Purchase\", \"Lead\", \"AddToCart\") or a LinkedIn rule\ntype enum (e.g. \"PURCHASE\", \"QUALIFIED_LEAD\"). For Google:\na unified standard event name (Purchase, Subscribe,\nCompleteRegistration, Lead, Schedule) or a Google\nConversionActionCategory enum value directly (e.g.\n\"PURCHASE\", \"SUBSCRIBE_PAID\", \"SIGNUP\", \"IMPORTED_LEAD\",\n\"BOOK_APPOINTMENT\"). Unknown values pass through to the\nplatform.\n"},"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,\nPURCHASE, ADD_TO_CART, QUALIFIED_LEAD, SUBMIT_APPLICATION\nrule types; the API rejects other combinations locally.\n"},"viewThroughAttributionWindowSize":{"type":"integer","enum":[1,7,30,90,365],"description":"LinkedIn only. Default 7. Same 365-day-window type\nrestriction applies as `postClickAttributionWindowSize`.\n"},"valueType":{"type":"string","enum":["DYNAMIC","FIXED","NO_VALUE"],"description":"LinkedIn only. DYNAMIC (default) uses the per-event `value`\nfrom `sendConversions`. FIXED uses the rule's `value` field.\nNO_VALUE drops monetary value entirely.\n"},"value":{"type":"object","required":["currencyCode","amount"],"description":"LinkedIn only. Static conversion value. Used when\n`valueType=FIXED`. The currency should match the ad\naccount's currency.\n","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\ntime:\n- ALL_CAMPAIGNS: associate the rule with every active,\n  paused, and draft campaign in the ad account\n- OBJECTIVE_BASED: associate only campaigns whose\n  objective matches the rule's type\n- NONE: don't auto-associate. Manage associations via\n  the `/associations` endpoints below.\nNote: auto-association runs once at create time; new\ncampaigns added after the rule still need explicit\nassociation.\n"},"countingType":{"type":"string","enum":["MANY_PER_CLICK","ONE_PER_CLICK"],"description":"Google Ads only. Whether to count multiple conversions from\nthe same click (MANY_PER_CLICK) or at most one\n(ONE_PER_CLICK). Defaults to MANY_PER_CLICK if omitted.\n"},"primaryForGoal":{"type":"boolean","description":"Google Ads only. When true, the conversion action is marked\nas primary and immediately influences Smart Bidding. Defaults\nto false (secondary, record-only) to avoid unintentionally\nsteering the customer's campaigns on creation.\n"}}}}}},"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),\nor the connected LinkedIn account lacks the `rw_conversions` scope (reconnect required).\n"},"404":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Platform does not support destination creation."},"409":{"description":"The account may also be inactive or need reconnection (code ads_connection_required). Reconnect it and read GET /v1/accounts for its current ID before retrying.\nGoogle Ads only. A conversion action with the given name already\nexists but has a different category. Use a different name or use\nthe existing destination. Error code: `IDEMPOTENCY_CONFLICT`.\n"},"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\nconversion rule. The `adAccountId` query parameter is required because\nLinkedIn rules are scoped to a sponsored ad account.\n","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":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Platform does not support fetching a single destination."},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\nfields: `name`, `enabled`, attribution windows, `valueType`, `value`,\n`attributionType`. The rule's `type` and parent ad account are\nintentionally not exposed for update. Recreate the rule if those\nneed to change.\n","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;\nthe route returns 400 if no patch fields are provided.\n","properties":{"adAccountId":{"type":"string"},"name":{"type":"string","maxLength":255},"enabled":{"type":"boolean","description":"Setting `false` is equivalent to calling DELETE: the\nrule will appear as `inactive` afterwards.\n"},"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,\nQUALIFIED_LEAD, SUBMIT_APPLICATION rule types.\n"},"viewThroughAttributionWindowSize":{"type":"integer","enum":[1,7,30,90,365],"description":"365 only allowed for LEAD, PURCHASE, ADD_TO_CART,\nQUALIFIED_LEAD, SUBMIT_APPLICATION rule types.\n"},"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":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Platform does not support updating destinations."},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\nrules; what their UI calls \"delete\" is the same `enabled: false` flip\nwe apply here. The rule remains fetchable via GET with\n`status: 'inactive'`; the unified discovery endpoint hides it by\ndefault.\n\n`adAccountId` may be passed as a query parameter (recommended) or as\na JSON body field for clients that can send DELETE bodies.\n","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":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Platform does not support deleting destinations."},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\nthis conversion rule. Auto-association on rule creation\nruns once at create time; campaigns created after the rule still need\nexplicit association.\n","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":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Platform does not support associations."},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\nper-campaign success/failure result so callers can retry only the\nrows that failed (e.g. wrong campaign type for the rule's objective).\n","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\nfailed. Inspect `failed[]` for details. Inputs that fail local\nURN validation are bucketed into `failed` without ever hitting\nLinkedIn.\n","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":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Platform does not support associations."},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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.\nPass `adAccountId` and `campaignIds` as query parameters\n(`campaignIds` is comma-separated). The route also accepts a JSON\nbody with the same fields for clients that prefer DELETE-with-body,\nbut the documented surface is query-only because some SDK code\ngenerators (e.g. Python) collapse query + body parameters with the\nsame name into a single kwarg.\n","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\nfailed. Inspect `failed[]` for details.\n","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`,\ncampaignIds exceeds 100 entries per request, or `accountId` is not\na valid id.\n"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads add-on or LinkedIn reconnect required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Platform does not support associations."},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\n(`externalWebsiteConversions`, `externalWebsitePostClickConversions`,\n`externalWebsitePostViewConversions`, `conversionValueInLocalCurrency`,\n`qualifiedLeads`, `costInLocalCurrency`) bucketed by date.\n\nDate-range constraints (passed through from LinkedIn):\n- `granularity=DAILY` is retained for ~6 months only\n- `granularity=ALL` with a range > 6 months auto-rounds to month boundaries\n- `granularity=MONTHLY`/`YEARLY` retains 24 months\n\nThrottle: LinkedIn caps adAnalytics at 45M metric values per 5-minute\nwindow across the calling token. Single-rule queries are well within\nthat limit; surfaces as 429 if hit.\n","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":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Platform does not support metrics readback."},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"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\n`POST /v1/whatsapp/conversions` for the given WhatsApp account.\nSourced from delivery logs (Axiom `late` dataset), so the visible\nwindow is bounded by log retention (about 30 days). Useful for\nrendering a \"recent activity\" panel on the conversions setup tab\nwithout standing up a parallel persistence layer.\n\nPer-event payload mirrors the structured log we write on every\nsuccessful send: `eventName`, `conversationId`, `eventsReceived`,\n`eventsFailed`, `traceId`, `durationMs`, and the wall-clock\n`timestamp`.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string"},"description":"WhatsApp 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`,\n`Purchase`, `AddToCart`, `InitiateCheckout`, `ViewContent`) to Meta's\nConversions API with `action_source = business_messaging` and\n`messaging_channel = whatsapp`. The endpoint looks up the originating\nCTWA click ID (`ctwa_clid`) captured on the first inbound message of\nthe conversation and replays it on every event so Meta can attribute\nthe conversion back to the Click-to-WhatsApp ad that drove the chat.\n\nConfiguration prerequisite on the WhatsApp account metadata:\n  - `metaCapiDatasetId`: the Meta dataset ID linked to the WABA.\n    Provision one with `POST /v1/whatsapp/dataset`.\n\nThe WABA ID (already set automatically at connect time) is forwarded as\n`user_data.whatsapp_business_account_id`, which is the per-channel\nattribution identifier Meta requires for WhatsApp events. No Facebook\nPage ID is needed (that field is the Messenger-branch identifier).\n\nIdentify the conversation by either `conversationId` (preferred) or\n`phoneE164` (digits only, no `+`). At least one is required. If the\nconversation has no captured `ctwa_clid`, the request returns 422\nbecause there is nothing to attribute.\n\nToken and dataset coupling: the WhatsApp account's accessToken must\nhave access to the configured `metaCapiDatasetId`. By default a WABA's\nsystem-user token is scoped to the WABA's own Business Manager and\ncannot post to a pixel owned by a different Business; Meta returns\ncode 100 in that case. Either share the dataset with the WhatsApp\napp's Business in BM, or use a dataset already in the same Business\nas the WABA.\n","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\n`conversationId` or `phoneE164` must be supplied (used to\nresolve the originating CTWA conversation). The route enforces\nthis at the Zod boundary; OpenAPI's `required` cannot express\nOR-required cleanly.\n","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\nCAPI for Business Messaging (Graph API v25.0). Other\nstandard pixel events including `Lead`,\n`CompleteRegistration`, `Subscribe`, `Schedule`, `Contact`,\n`StartTrial`, `AddPaymentInfo`, `Search`, and\n`SubmitApplication` are rejected with subcode 2804066\n(\"Messaging Event Invalid Event Type\") on\n`action_source = business_messaging` events. Custom event\nnames are also rejected.\n\nUse `LeadSubmitted` (NOT `Lead`) for lead-style conversions.\n"},"eventTime":{"type":"number","description":"Unix seconds. Defaults to the time of the request when\nomitted. Meta's attribution window is 7 days from click;\nevents older than that lose attribution.\n"},"eventId":{"type":"string","minLength":1,"description":"Stable dedup key. Reuse to suppress duplicate events\n(Meta dedupes against pixel events with the same id).\n"},"conversationId":{"type":"string","minLength":1,"description":"Zernio Conversation `_id` (preferred lookup). The\nconversation must have a captured `ctwa_clid` in metadata\n(set automatically by the WhatsApp webhook on the first\ninbound message after a CTWA ad click).\n"},"phoneE164":{"type":"string","minLength":1,"description":"Contact phone number, digits only with no '+'. When used\nin lieu of `conversationId`, the handler resolves to the\nmost recent CTWA-attributed conversation for this phone\non the supplied account.\n"},"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\nbefore sending to Meta.\n"},"testCode":{"type":"string","description":"Meta `test_event_code` passthrough. Routes the event to\nthe Test Events tab in Events Manager instead of the\nproduction dataset, useful for development.\n"}}}}}},"responses":{"200":{"description":"Event submitted to Meta. Inspect `eventsFailed` and `failures[]`\nto detect partial failures. A 200 does not mean Meta accepted the\nevent; the status reflects \"request reached Meta\" only.\n","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\naccepted.\n","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\ntickets.\n"}}}}}},"400":{"description":"Invalid body."},"401":{"$ref":"#/components/responses/Unauthorized"},"404":{"description":"Conversation not found."},"422":{"description":"Configuration missing (no `metaCapiDatasetId` on the account, set\nit via POST /v1/whatsapp/dataset) OR the resolved conversation has\nno captured `ctwa_clid`.\n"}}}},"/v1/ads/messaging":{"post":{"x-resource-group":"ads","operationId":"createMessagingAd","tags":["Messaging Ads"],"x-platforms":["meta"],"summary":"Create messaging ad","description":"Creates a click-to-message ad; `destination` selects where the tapped ad opens a\nconversation: WhatsApp, the Page's Messenger inbox or the linked Instagram account's Direct inbox.\nThe ad set is created with the matching destination_type and\nCONVERSATIONS optimization; the campaign objective defaults to OUTCOME_ENGAGEMENT.\nSupports single-creative and multi-creative shapes. Supersedes POST /v1/ads/ctwa\n(deprecated, equivalent to `destination: whatsapp`).\nExisting posts and reels are supported through `existingPostId` or\n`objectStoryId`, either per creative or at the top level. Omit fresh\nmedia and copy for that creative. Optional `whatsappPhoneNumber` selects\na number already paired with the Page (WhatsApp destination only).","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."}}}]},"example":{"accountId":"69ca62446cb7b8cf4cb13603","adAccountId":"act_757082720485182","name":"WhatsApp existing post","destination":"whatsapp","creatives":[{"objectStoryId":"811889972008357_123456789"}],"budgetAmount":2.61,"budgetType":"daily","status":"PAUSED"}}}},"responses":{"201":{"description":"Ad(s) created and submitted for review. The route shares its handler with\n`POST /v1/ads/ctwa`, so the body is the same tagged union discriminated by\n`adType`: `single` carries `{ adType, ad, message }`, and `multi` carries\n`{ adType, ads, platformCampaignId, platformAdSetId, message }`.\n","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 input"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"422":{"description":"No Facebook Page resolved for the account"},"502":{"description":"Meta accepted the request then failed to produce the media (upload session, chunk transfer, processing timeout, or a response with no image hash). Inspect `platformError.reason`."}}}},"/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`\nvia a tel: link. The ad set is destination_type PHONE_CALL optimizing QUALITY_CALL\nand the campaign objective defaults to OUTCOME_LEADS.\nSupports 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. The route shares its handler with\n`POST /v1/ads/ctwa`, so the body is the same tagged union discriminated by\n`adType`: `single` carries `{ adType, ad, message }`, and `multi` carries\n`{ adType, ads, platformCampaignId, platformAdSetId, message }`.\n","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 input"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"422":{"description":"No Facebook Page resolved for the account"},"502":{"description":"Meta accepted the request then failed to produce the media (upload session, chunk transfer, processing timeout, or a response with no image hash). Inspect `platformError.reason`."}}}},"/v1/ads/ctwa":{"post":{"x-resource-group":"ads","operationId":"createCtwaAd","tags":["Messaging Ads"],"x-platforms":["meta"],"summary":"Create CTWA ad (deprecated)","deprecated":true,"description":"Deprecated: use POST /v1/ads/messaging with `destination: whatsapp`. This endpoint stays available for back-compat; no removal planned.\n\nCreates 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.\n\nSupports two mutually-exclusive shapes:\n\n- **Single-creative**: supply top-level `headline`, `body`, and one of `imageUrl` / `video`, or an `existingPostId` / `objectStoryId` reference. Creates 1 campaign + 1 ad set + 1 ad.\n\n- **Multi-creative**: supply a `creatives[]` array with N entries (each carrying fresh media and copy or an existing post reference). 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.\n\n**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.\n\nPrerequisites 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.\nExisting posts and reels are supported through `existingPostId` or\n`objectStoryId`, either per creative or at the top level. Omit fresh\nmedia and copy for that creative. Optional `whatsappPhoneNumber` selects\na number already paired with the Page (WhatsApp destination only).","security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/CtwaAdRequestBody"},"example":{"accountId":"69ca62446cb7b8cf4cb13603","adAccountId":"act_757082720485182","name":"WhatsApp existing post","creatives":[{"objectStoryId":"811889972008357_123456789"}],"budgetAmount":2.61,"budgetType":"daily","status":"PAUSED"}}}},"responses":{"201":{"description":"CTWA ad(s) created and submitted to Meta for review. Response is a\ntagged union discriminated by `adType`:\n\n- `adType: \"single\"` → single-creative request: `{ adType, ad,\n  message }` where `ad` is the persisted Ad document.\n- `adType: \"multi\"` → multi-creative request: `{ adType, ads,\n  platformCampaignId, platformAdSetId, message }` where `ads` is\n  the array of N persisted Ad documents all sharing the returned\n  campaign and ad set IDs.\n\nGenerated SDK clients can narrow on `adType` instead of sniffing\nfor field presence.\n","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":"Forbidden. Also returned with code `ads_allowance_exceeded` when the team has no payment method on file and has reached the 500 free live ads: add a card to resume."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"422":{"description":"Page is not connected to a verified WhatsApp number."},"502":{"description":"Meta rejected the request (e.g. WABA business verification\nmissing). Inspect `platformError` for the upstream Meta payload.\n"}}}},"/v1/accounts/{accountId}/custom-conversions":{"get":{"x-resource-group":"accounts","operationId":"listCustomConversions","tags":["Ad Accounts"],"x-platforms":["meta"],"summary":"List custom conversions","description":"The ad account's Meta custom conversions, including archived ones (`isArchived`).","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"Meta ads SocialAccount id."},{"name":"adAccountId","in":"query","required":true,"schema":{"type":"string"},"description":"Meta ad account id (act_<n>)."}],"responses":{"200":{"description":"Custom conversions","content":{"application/json":{"schema":{"type":"object","properties":{"adAccountId":{"type":"string"},"data":{"type":"array","items":{"$ref":"#/components/schemas/CustomConversion"}}}}}}},"400":{"description":"Invalid input, or Meta rejected the query"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access required, or the token lacks the ads permissions."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}},"post":{"x-resource-group":"accounts","operationId":"createCustomConversion","tags":["Ad Accounts"],"x-platforms":["meta"],"summary":"Create custom conversion","description":"Provision the Meta custom conversion an ads flow optimises toward, and hand back the\n`customConversionId` for `promotedObject.customConversionId` on POST /v1/ads/create.\nRemoves the manual \"create it in Ads Manager first\" step.\n\n**Reuse is ours, not Meta's.** Meta's create is not idempotent, so a retried request\nwould otherwise mint a duplicate carrying none of the original's optimisation history.\nA non-archived conversion with the same `name` on the same `pixelId` is returned\ninstead of created, with `reused: true` and a 200 rather than a 201.\n\n`rule` is forwarded verbatim in Meta's own grammar (e.g.\n`{\"url\": {\"i_contains\": \"thank-you\"}}`); Meta validates it and rejects a malformed one\nwith \"A conversion rule is required at creation time\".","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"Meta ads SocialAccount id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["adAccountId","name","pixelId","customEventType","rule"],"properties":{"adAccountId":{"type":"string","description":"Platform ad account id (Meta act_<n>, Google customer id, LinkedIn account id, ...)."},"name":{"type":"string","maxLength":255,"description":"Also the reuse key, together with pixelId."},"pixelId":{"type":"string","description":"Meta pixel id (event_source_id). From GET /v1/accounts/{accountId}/tracking-tags."},"customEventType":{"type":"string","description":"Meta custom_event_type, e.g. LEAD, PURCHASE, OTHER."},"rule":{"type":"object","description":"Meta conversion rule, forwarded verbatim."}}}}}},"responses":{"200":{"description":"An existing custom conversion was reused","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomConversionResult"}}}},"201":{"description":"Custom conversion created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/CustomConversionResult"}}}},"400":{"description":"Invalid input, or Meta rejected the conversion (bad rule, per-account cap reached)"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access required, or the token lacks the ads permissions."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"}}}},"/v1/accounts/{accountId}/business-agent":{"get":{"x-resource-group":"messages","operationId":"getBusinessAgentStatus","tags":["Business Agent"],"summary":"Get agent setup status","description":"One read that says where the merchant is: whether the number is eligible, whether the\nMeta Business Agent terms are accepted, whether an agent exists, whether it is on, and its\nsettings. `manualSteps` lists what Zernio can verify is still pending (accepting the terms\nin WhatsApp Manager); `unverifiedSteps` lists what Meta exposes no state for (the payment\nmethod in Billing Hub). Never fails for those pre-setup states; it reports them as flags.\n","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"responses":{"200":{"description":"Setup status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentStatus"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/onboard":{"post":{"x-resource-group":"messages","operationId":"onboardBusinessAgent","tags":["Business Agent"],"summary":"Create the agent","description":"Creates the Meta Business Agent on the number and schedules Meta's data preparation.\nRequires the terms to be accepted; eligibility is checked first and an ineligible\nnumber answers 403 `business_agent_not_eligible`. Not idempotent: call it once, then\nconfigure knowledge and skills, then enable it through the settings. Configuration\ncalls made in the first minute can still answer `business_agent_not_found` while Meta\nprepares the workspace.\n","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"responses":{"201":{"description":"Agent created","content":{"application/json":{"schema":{"type":"object","required":["agentId"],"properties":{"agentId":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"},"409":{"description":"Meta rejected the state change (code business_agent_conflict)."}}}},"/v1/accounts/{accountId}/business-agent/settings":{"get":{"x-resource-group":"messages","operationId":"listBusinessAgentSettings","tags":["Business Agent"],"summary":"List agent settings","description":"Settings of every agent configured on the number (normally one). Pass `agentId` to read one.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"agentId","in":"query","required":false,"schema":{"type":"string"}}],"responses":{"200":{"description":"Settings","content":{"application/json":{"schema":{"type":"object","required":["settings"],"properties":{"settings":{"type":"array","items":{"$ref":"#/components/schemas/BusinessAgentSettings"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"patch":{"x-resource-group":"messages","operationId":"updateBusinessAgentSettings","tags":["Business Agent"],"summary":"Update agent settings","description":"Partial update: fields you omit keep their value. `rollout.enabled: true` turns the agent\non for new conversations; `false` stops it on every thread. Turning it on for `EVERYONE`\nneeds a payment method on the Business Agent billable account (Meta accepts the call but\ndelivers nothing without one); `ALLOWLISTED_ONLY` does not, which is how you test with a\nfew numbers before billing. `never_say_phrases` replaces the whole list.\n","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"agentId","in":"query","required":false,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","properties":{"rollout":{"type":"object","required":["enabled"],"properties":{"enabled":{"type":"boolean"}}},"handoff":{"type":"object","required":["enabled"],"properties":{"enabled":{"type":"boolean"},"message":{"type":"string","maxLength":1024},"message_selection":{"type":"string","enum":["DEFAULT","AGENT","CUSTOM"]}}},"followup":{"type":"object","required":["enabled"],"properties":{"enabled":{"type":"boolean"},"followup_interval_in_seconds":{"type":"integer","enum":[0,300,900,1800,3600,7200,28800,86400]},"message":{"type":"string","maxLength":1024}}},"ai_audience":{"type":"string","enum":["EVERYONE","ALLOWLISTED_ONLY"]},"never_say_phrases":{"type":"array","items":{"type":"string"},"description":"Exact phrases the agent must never say; the full replacement list."}}}}}},"responses":{"200":{"description":"Updated settings","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentSettings"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/allowlist":{"get":{"x-resource-group":"messages","operationId":"listBusinessAgentAllowlist","tags":["Business Agent"],"summary":"List allowlisted consumers","description":"Consumers the agent answers while `ai_audience` is ALLOWLISTED_ONLY.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"responses":{"200":{"description":"Allowlist","content":{"application/json":{"schema":{"type":"object","required":["entries"],"properties":{"entries":{"type":"array","items":{"$ref":"#/components/schemas/BusinessAgentAllowlistEntry"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"post":{"x-resource-group":"messages","operationId":"addBusinessAgentAllowlistEntry","tags":["Business Agent"],"summary":"Allowlist a consumer","description":"One E.164 number per call. Not idempotent.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["consumer_phone_number"],"properties":{"consumer_phone_number":{"type":"string","example":"+34600000000"}}}}}},"responses":{"201":{"description":"Entry added","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentAllowlistEntry"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/allowlist/{entryId}":{"delete":{"x-resource-group":"messages","operationId":"removeBusinessAgentAllowlistEntry","tags":["Business Agent"],"summary":"Remove an allowlisted consumer","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"entryId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"$ref":"#/components/responses/BusinessAgentDeleted"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/business-information":{"get":{"x-resource-group":"messages","operationId":"getBusinessAgentBusinessInformation","tags":["Business Agent"],"summary":"Get business information","description":"Payment methods, return policy, how to buy, shipping, description and contact details the agent answers from. Empty values until configured.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"responses":{"200":{"description":"Business information","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentBusinessInformation"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"put":{"x-resource-group":"messages","operationId":"replaceBusinessAgentBusinessInformation","tags":["Business Agent"],"summary":"Replace business information","description":"Full replacement: every field you send overwrites the stored value; fields you omit are cleared.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentBusinessInformation"}}}},"responses":{"200":{"description":"Stored business information","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentBusinessInformation"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"delete":{"x-resource-group":"messages","operationId":"resetBusinessAgentBusinessInformation","tags":["Business Agent"],"summary":"Reset business information","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"responses":{"200":{"$ref":"#/components/responses/BusinessAgentDeleted"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/faqs":{"get":{"x-resource-group":"messages","operationId":"listBusinessAgentFaqs","tags":["Business Agent"],"summary":"List FAQs","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"responses":{"200":{"description":"FAQs","content":{"application/json":{"schema":{"type":"object","required":["faqs"],"properties":{"faqs":{"type":"array","items":{"$ref":"#/components/schemas/BusinessAgentFaq"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"post":{"x-resource-group":"messages","operationId":"createBusinessAgentFaq","tags":["Business Agent"],"summary":"Create a FAQ","description":"One specific question per entry; beyond a few hundred entries retrieval quality drops. Not idempotent.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentFaqInput"}}}},"responses":{"201":{"description":"FAQ created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentFaq"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"},"409":{"description":"Meta rejected the entry (code business_agent_conflict)."}}}},"/v1/accounts/{accountId}/business-agent/faqs/{faqId}":{"get":{"x-resource-group":"messages","operationId":"getBusinessAgentFaq","tags":["Business Agent"],"summary":"Get a FAQ","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"faqId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"FAQ","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentFaq"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"put":{"x-resource-group":"messages","operationId":"updateBusinessAgentFaq","tags":["Business Agent"],"summary":"Update a FAQ","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"faqId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentFaqInput"}}}},"responses":{"200":{"description":"FAQ updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentFaq"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"delete":{"x-resource-group":"messages","operationId":"deleteBusinessAgentFaq","tags":["Business Agent"],"summary":"Delete a FAQ","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"faqId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"$ref":"#/components/responses/BusinessAgentDeleted"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/websites":{"get":{"x-resource-group":"messages","operationId":"listBusinessAgentWebsites","tags":["Business Agent"],"summary":"List crawled websites","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"responses":{"200":{"description":"Websites","content":{"application/json":{"schema":{"type":"object","required":["websites"],"properties":{"websites":{"type":"array","items":{"$ref":"#/components/schemas/BusinessAgentWebsite"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"post":{"x-resource-group":"messages","operationId":"addBusinessAgentWebsite","tags":["Business Agent"],"summary":"Add a website to crawl","description":"Meta crawls the site into the agent knowledge and recrawls it periodically; check `crawl_status` and `crawl_error` on read. Not idempotent.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentWebsiteInput"}}}},"responses":{"201":{"description":"Website added","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentWebsite"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/websites/{websiteId}":{"get":{"x-resource-group":"messages","operationId":"getBusinessAgentWebsite","tags":["Business Agent"],"summary":"Get a crawled website","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"websiteId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Website","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentWebsite"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"put":{"x-resource-group":"messages","operationId":"updateBusinessAgentWebsite","tags":["Business Agent"],"summary":"Update a crawled website","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"websiteId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentWebsiteInput"}}}},"responses":{"200":{"description":"Website updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentWebsite"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"delete":{"x-resource-group":"messages","operationId":"deleteBusinessAgentWebsite","tags":["Business Agent"],"summary":"Remove a crawled website","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"websiteId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"$ref":"#/components/responses/BusinessAgentDeleted"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/files":{"get":{"x-resource-group":"messages","operationId":"listBusinessAgentFiles","tags":["Business Agent"],"summary":"List knowledge files","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"responses":{"200":{"description":"Files","content":{"application/json":{"schema":{"type":"object","required":["files"],"properties":{"files":{"type":"array","items":{"$ref":"#/components/schemas/BusinessAgentKnowledgeFile"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"post":{"x-resource-group":"messages","operationId":"uploadBusinessAgentFile","tags":["Business Agent"],"summary":"Upload a knowledge file","description":"Accepted types: pdf, doc, docx, png, jpg, jpeg, plus csv and xlsx when Meta enabled\nextraction on the asset. Meta's limit is 100 MB. Two ways to send the file:\n- JSON `{ url, fileName }`: Zernio downloads the file (public https URL, no redirects,\n  capped at 100 MB) and forwards it. Use this for anything above a few megabytes.\n- multipart form-data with a `file` part (and an optional `fileName`): bounded by the\n  request body limit of about 4.5 MB; larger uploads must use the `url` form.\nNot idempotent.\n","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["url"],"properties":{"url":{"type":"string","format":"uri","description":"Publicly downloadable file URL."},"fileName":{"type":"string","description":"Defaults to the last path segment of the URL."}}}},"multipart/form-data":{"schema":{"type":"object","required":["file"],"properties":{"file":{"type":"string","format":"binary"},"fileName":{"type":"string"}}}}}},"responses":{"201":{"description":"File uploaded","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentKnowledgeFile"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"},"413":{"description":"File larger than 100 MB (code payload_too_large). A multipart body above the request limit is rejected by the host before the route runs."}}}},"/v1/accounts/{accountId}/business-agent/files/{fileId}":{"get":{"x-resource-group":"messages","operationId":"getBusinessAgentFile","tags":["Business Agent"],"summary":"Get a knowledge file","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"fileId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"File","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentKnowledgeFile"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"delete":{"x-resource-group":"messages","operationId":"deleteBusinessAgentFile","tags":["Business Agent"],"summary":"Delete a knowledge file","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"fileId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"$ref":"#/components/responses/BusinessAgentDeleted"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/skills":{"get":{"x-resource-group":"messages","operationId":"listBusinessAgentSkills","tags":["Business Agent"],"summary":"List skills","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"responses":{"200":{"description":"Skills","content":{"application/json":{"schema":{"type":"object","required":["skills"],"properties":{"skills":{"type":"array","items":{"$ref":"#/components/schemas/BusinessAgentSkill"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"post":{"x-resource-group":"messages","operationId":"createBusinessAgentSkill","tags":["Business Agent"],"summary":"Create a skill","description":"Behavioral instructions in the brand voice. Reads back `pending_review` until Meta content review passes it. Not idempotent.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentSkillInput"}}}},"responses":{"201":{"description":"Skill created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentSkill"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/skills/{skillId}":{"get":{"x-resource-group":"messages","operationId":"getBusinessAgentSkill","tags":["Business Agent"],"summary":"Get a skill","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"skillId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Skill","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentSkill"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"put":{"x-resource-group":"messages","operationId":"updateBusinessAgentSkill","tags":["Business Agent"],"summary":"Update a skill","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"skillId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentSkillInput"}}}},"responses":{"200":{"description":"Skill updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentSkill"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"delete":{"x-resource-group":"messages","operationId":"deleteBusinessAgentSkill","tags":["Business Agent"],"summary":"Delete a skill","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"skillId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"$ref":"#/components/responses/BusinessAgentDeleted"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/ui-skills":{"get":{"x-resource-group":"messages","operationId":"listBusinessAgentUiSkills","tags":["Business Agent"],"summary":"List UI skills","description":"Cursor paged; follow `paging.cursors.after` until `paging.next` is absent.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"before","in":"query","required":false,"schema":{"type":"string"}},{"name":"after","in":"query","required":false,"schema":{"type":"string"}},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":100}}],"responses":{"200":{"description":"UI skills","content":{"application/json":{"schema":{"type":"object","required":["uiSkills"],"properties":{"uiSkills":{"type":"array","items":{"$ref":"#/components/schemas/BusinessAgentUiSkill"}},"paging":{"type":"object","additionalProperties":true}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"post":{"x-resource-group":"messages","operationId":"createBusinessAgentUiSkill","tags":["Business Agent"],"summary":"Create a UI skill","description":"Tells the agent when to send a rich component (CTA URL button, image, carousel, list, reply buttons, location, Flow) and what to put in it. Not idempotent.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentUiSkillInput"}}}},"responses":{"201":{"description":"UI skill created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentUiSkill"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/ui-skills/{uiSkillId}":{"get":{"x-resource-group":"messages","operationId":"getBusinessAgentUiSkill","tags":["Business Agent"],"summary":"Get a UI skill","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"uiSkillId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"UI skill","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentUiSkill"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"put":{"x-resource-group":"messages","operationId":"updateBusinessAgentUiSkill","tags":["Business Agent"],"summary":"Update a UI skill","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"uiSkillId","in":"path","required":true,"schema":{"type":"string"}}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentUiSkillInput"}}}},"responses":{"200":{"description":"UI skill updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentUiSkill"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"delete":{"x-resource-group":"messages","operationId":"deleteBusinessAgentUiSkill","tags":["Business Agent"],"summary":"Delete a UI skill","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"uiSkillId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"$ref":"#/components/responses/BusinessAgentDeleted"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/connectors":{"get":{"x-resource-group":"messages","operationId":"listBusinessAgentConnectors","tags":["Business Agent"],"summary":"List connectors","description":"External APIs the agent may call. `connection_status` says whether Meta can currently reach each one.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"responses":{"200":{"description":"Connectors","content":{"application/json":{"schema":{"type":"object","required":["connectors"],"properties":{"connectors":{"type":"array","items":{"$ref":"#/components/schemas/BusinessAgentConnector"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"post":{"x-resource-group":"messages","operationId":"createBusinessAgentConnector","tags":["Business Agent"],"summary":"Create a connector","description":"Base URL plus how to authenticate (OAuth client credentials, API key or none). Names are unique per number. Not idempotent.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentConnectorInput"}}}},"responses":{"201":{"description":"Connector created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentConnector"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"},"409":{"description":"A connector with that name already exists (code business_agent_conflict)."}}}},"/v1/accounts/{accountId}/business-agent/connectors/{connectorId}":{"get":{"x-resource-group":"messages","operationId":"getBusinessAgentConnector","tags":["Business Agent"],"summary":"Get a connector","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"$ref":"#/components/parameters/BusinessAgentConnectorId"}],"responses":{"200":{"description":"Connector","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentConnector"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"put":{"x-resource-group":"messages","operationId":"updateBusinessAgentConnector","tags":["Business Agent"],"summary":"Update a connector","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"$ref":"#/components/parameters/BusinessAgentConnectorId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentConnectorInput"}}}},"responses":{"200":{"description":"Connector updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentConnector"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"delete":{"x-resource-group":"messages","operationId":"deleteBusinessAgentConnector","tags":["Business Agent"],"summary":"Delete a connector","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"$ref":"#/components/parameters/BusinessAgentConnectorId"}],"responses":{"200":{"$ref":"#/components/responses/BusinessAgentDeleted"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/connectors/{connectorId}/credentials":{"post":{"x-resource-group":"messages","operationId":"setBusinessAgentConnectorCredentials","tags":["Business Agent"],"summary":"Set connector credentials","description":"Set or rotate the connector's credentials in place: `kind: api_key`, `kind: oauth`\n(client credentials) or `kind: certificate` (mTLS client certificate). Meta has no call\nthat removes a credential layer; change the connector's `auth_type` or delete it instead.\n","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"$ref":"#/components/parameters/BusinessAgentConnectorId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"oneOf":[{"type":"object","required":["kind","api_key_config"],"properties":{"kind":{"type":"string","enum":["api_key"]},"api_key_config":{"$ref":"#/components/schemas/BusinessAgentApiKeyConfig"}}},{"type":"object","required":["kind","oauth_config"],"properties":{"kind":{"type":"string","enum":["oauth"]},"oauth_config":{"$ref":"#/components/schemas/BusinessAgentOAuthClientCredentials"}}},{"type":"object","required":["kind","client_certificate","client_key"],"properties":{"kind":{"type":"string","enum":["certificate"]},"client_certificate":{"type":"string","description":"PEM."},"client_key":{"type":"string","description":"PEM."},"ca_certificate":{"type":"string","description":"PEM."}}}]}}}},"responses":{"200":{"description":"Connector with the new credential metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentConnector"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/connectors/{connectorId}/refresh-tools":{"post":{"x-resource-group":"messages","operationId":"refreshBusinessAgentConnectorTools","tags":["Business Agent"],"summary":"Refresh MCP connector tools","description":"Re-discovers the tools of an MCP connector. A failed discovery keeps the previous tool set and reports an ERROR sync status inside a 200.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"$ref":"#/components/parameters/BusinessAgentConnectorId"}],"responses":{"200":{"description":"Connector with updated tool sync metadata","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentConnector"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/connectors/{connectorId}/logs":{"get":{"x-resource-group":"messages","operationId":"getBusinessAgentConnectorLogs","tags":["Business Agent"],"summary":"Get connector failure logs","description":"Third-party failures over the last 7 days (window at most 7 days, default the last 24 hours). Each entry carries `failure_code_name` and `error_message`.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"$ref":"#/components/parameters/BusinessAgentConnectorId"},{"name":"startTime","in":"query","required":false,"schema":{"type":"integer"},"description":"Unix seconds."},{"name":"endTime","in":"query","required":false,"schema":{"type":"integer"},"description":"Unix seconds."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":1000}},{"name":"toolId","in":"query","required":false,"schema":{"type":"string"}},{"name":"includeStats","in":"query","required":false,"schema":{"type":"boolean"},"description":"Add success rate and latency percentiles."},{"name":"summaryOnly","in":"query","required":false,"schema":{"type":"boolean"},"description":"Aggregate failure patterns instead of entries."},{"name":"topN","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":50}}],"responses":{"200":{"description":"Logs","content":{"application/json":{"schema":{"type":"object","properties":{"data":{"type":"array","items":{"type":"object","additionalProperties":true}},"stats":{"type":"object","additionalProperties":true}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/connectors/{connectorId}/tools":{"get":{"x-resource-group":"messages","operationId":"listBusinessAgentConnectorTools","tags":["Business Agent"],"summary":"List connector tools","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"$ref":"#/components/parameters/BusinessAgentConnectorId"}],"responses":{"200":{"description":"Tools","content":{"application/json":{"schema":{"type":"object","required":["tools"],"properties":{"tools":{"type":"array","items":{"$ref":"#/components/schemas/BusinessAgentConnectorTool"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"post":{"x-resource-group":"messages","operationId":"createBusinessAgentConnectorTool","tags":["Business Agent"],"summary":"Create a connector tool","description":"One operation on the connector, with the request definition Meta uses to build the outbound call from the conversation. Type the body params explicitly. Not idempotent.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"$ref":"#/components/parameters/BusinessAgentConnectorId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentConnectorToolInput"}}}},"responses":{"201":{"description":"Tool created","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentConnectorTool"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/connectors/{connectorId}/tools/{toolId}":{"get":{"x-resource-group":"messages","operationId":"getBusinessAgentConnectorTool","tags":["Business Agent"],"summary":"Get a connector tool","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"$ref":"#/components/parameters/BusinessAgentConnectorId"},{"$ref":"#/components/parameters/BusinessAgentToolId"}],"responses":{"200":{"description":"Tool","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentConnectorTool"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"put":{"x-resource-group":"messages","operationId":"updateBusinessAgentConnectorTool","tags":["Business Agent"],"summary":"Update a connector tool","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"$ref":"#/components/parameters/BusinessAgentConnectorId"},{"$ref":"#/components/parameters/BusinessAgentToolId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentConnectorToolInput"}}}},"responses":{"200":{"description":"Tool updated","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentConnectorTool"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"delete":{"x-resource-group":"messages","operationId":"deleteBusinessAgentConnectorTool","tags":["Business Agent"],"summary":"Delete a connector tool","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"$ref":"#/components/parameters/BusinessAgentConnectorId"},{"$ref":"#/components/parameters/BusinessAgentToolId"}],"responses":{"200":{"$ref":"#/components/responses/BusinessAgentDeleted"},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/connectors/{connectorId}/tools/{toolId}/run":{"post":{"x-resource-group":"messages","operationId":"runBusinessAgentConnectorTool","tags":["Business Agent"],"summary":"Run a connector tool once","description":"Executes the tool against the merchant API and returns the raw upstream result, to check a connector before the agent relies on it.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"$ref":"#/components/parameters/BusinessAgentConnectorId"},{"$ref":"#/components/parameters/BusinessAgentToolId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["input"],"properties":{"input":{"type":"string","description":"JSON string with the tool arguments."}}}}}},"responses":{"200":{"description":"Tool result","content":{"application/json":{"schema":{"type":"object","properties":{"output":{"type":"string"},"status":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/budget":{"get":{"x-resource-group":"messages","operationId":"getBusinessAgentBudget","tags":["Business Agent"],"summary":"Get usage budgets","description":"Caps over rolling windows for the Business Manager that owns the number. An empty list means unlimited.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"responses":{"200":{"description":"Budgets","content":{"application/json":{"schema":{"type":"object","required":["budgets"],"properties":{"budgets":{"type":"array","items":{"$ref":"#/components/schemas/BusinessAgentBudget"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"put":{"x-resource-group":"messages","operationId":"replaceBusinessAgentBudget","tags":["Business Agent"],"summary":"Replace usage budgets","description":"The full desired set: budgets left out are removed, an empty list returns to unlimited. Pass `budget_id` to edit one in place. When a cap is hit the agent finishes its turn, stops answering and hands the thread to a human until the window rolls over.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["budgets"],"properties":{"budgets":{"type":"array","items":{"$ref":"#/components/schemas/BusinessAgentBudget"}}}}}}},"responses":{"200":{"description":"Budgets after the update","content":{"application/json":{"schema":{"type":"object","required":["budgets"],"properties":{"budgets":{"type":"array","items":{"$ref":"#/components/schemas/BusinessAgentBudget"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/test-messages":{"post":{"x-resource-group":"messages","operationId":"sendBusinessAgentTestMessage","tags":["Business Agent"],"summary":"Send a test message","description":"Runs the message through the full agent pipeline in Meta sandbox with no WhatsApp user and no token billing. Pass back `conversationId` to continue a thread. Meta rate-limits it per number per hour.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["message"],"properties":{"message":{"type":"string","maxLength":4096},"conversationId":{"type":"string"}}}}}},"responses":{"200":{"description":"Agent reply","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentTestMessageResponse"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"},"429":{"description":"Meta hourly test-message limit reached (code rate_limited, Retry-After when known)."}}}},"/v1/accounts/{accountId}/business-agent/events":{"post":{"x-resource-group":"messages","operationId":"sendBusinessAgentEvent","tags":["Business Agent"],"summary":"Send a business event","description":"Tell the agent something happened in your systems (order shipped, document verified) so it messages the consumer about it. The consumer must already have a conversation with the number. Answers 202 with the event id; poll it for the outcome.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["to","type","description","payload"],"properties":{"to":{"type":"string","description":"Consumer E.164 phone number.","example":"+34600000000"},"type":{"type":"string","maxLength":256,"example":"order_shipped"},"description":{"type":"string","maxLength":1024},"payload":{"type":"string","maxLength":4096,"description":"JSON string passed to the agent verbatim."}}}}}},"responses":{"202":{"description":"Event accepted","content":{"application/json":{"schema":{"type":"object","required":["status"],"properties":{"status":{"type":"string","example":"accepted"},"agent_event_id":{"type":"string"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/events/{eventId}":{"get":{"x-resource-group":"messages","operationId":"getBusinessAgentEvent","tags":["Business Agent"],"summary":"Get a business event status","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"eventId","in":"path","required":true,"schema":{"type":"string"}}],"responses":{"200":{"description":"Event status","content":{"application/json":{"schema":{"$ref":"#/components/schemas/BusinessAgentEventStatus"}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/v1/accounts/{accountId}/business-agent/evals":{"get":{"x-resource-group":"messages","operationId":"readBusinessAgentEvals","tags":["Business Agent"],"summary":"Read evaluation data","description":"Without query parameters, lists the evaluation scenarios (`eval_cases`). With `jobId`,\npolls a run started with POST. With `summaryIds`, returns the aggregated insight reports.\nWith `evalIds`, returns per-conversation evaluation details. One of the three at a time.\n","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"},{"name":"jobId","in":"query","required":false,"schema":{"type":"string"}},{"name":"summaryIds","in":"query","required":false,"schema":{"type":"string"},"description":"Comma-separated summary ids."},{"name":"evalIds","in":"query","required":false,"schema":{"type":"string"},"description":"Comma-separated evaluation ids."}],"responses":{"200":{"description":"Evaluation data as Meta returns it for the selected read","content":{"application/json":{"schema":{"type":"object","additionalProperties":true}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}},"post":{"x-resource-group":"messages","operationId":"startBusinessAgentEvalRun","tags":["Business Agent"],"summary":"Start an evaluation run","description":"Simulates the given scenarios against the agent and scores them. Answers 202 with a `job_id` to poll with GET.","security":[{"bearerAuth":[]}],"parameters":[{"$ref":"#/components/parameters/BusinessAgentAccountId"}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["evalCaseIds"],"properties":{"evalCaseIds":{"type":"array","items":{"type":"string"},"minItems":1,"maxItems":100}}}}}},"responses":{"202":{"description":"Run started","content":{"application/json":{"schema":{"type":"object","required":["job_id","status"],"properties":{"job_id":{"type":"string"},"status":{"type":"string","example":"RUNNING"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"$ref":"#/components/responses/BusinessAgentForbidden"},"404":{"$ref":"#/components/responses/BusinessAgentNotFound"}}}},"/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\nconnected ads account can see. Pass `?adAccountId=act_...` (Meta only)\nto scope the list to a single ad account; omit it to list every pixel\nreachable by the token (the name is then suffixed with the ad account\nit was discovered on, for disambiguation). The list view omits `code`.\nCall `getTrackingTag` for the install snippet and full detail (Meta\nonly; OpenAI Ads has no get-by-id endpoint).\n\nMeta (platform `metaads`) and OpenAI Ads (platform `openaiads`); other\nplatforms return 405. The `accountId` must be the ads SocialAccount\ncreated by the Ads add-on connect flow (Meta) or the OpenAI Ads\nconnect flow, not a Facebook/Instagram posting account. Get your Meta\n`act_...` ids from `GET /v1/ads/accounts`; `adAccountId` is ignored for\nOpenAI Ads (one API key maps to exactly one ad account).\n","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":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Platform does not support listing tracking tags."},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"502":{"description":"Meta was unreachable or returned an unclassified error (type: platform_error; the raw Meta payload is in platformError). Retryable."}}},"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`,\nwhere `name` is the only input). Returns the created tag including its\ninstall `code`. The pixel is owned by the Business Manager that owns the\nad account; a pixel created on a personal (non-BM) ad account ends up\nwith `ownerBusinessId: null` and can't be shared with other ad accounts.\n\nCreating a Meta pixel does NOT install it. Install the returned `code`\nsnippet on the site, or send events server-side via\n`POST /v1/ads/conversions`. The check `installed` is derived from\n`lastFiredTime`.\n\nOpenAI Ads: creates an OpenAI pixel AND provisions a Conversions API\nkey for it in the same call (`adAccountId` is required by this\nendpoint but ignored: one API key maps to exactly one ad account, so\nthere's nothing to select). Returns 422 (`FEATURE_NOT_AVAILABLE`) if\nthe ad account isn't enabled for pixel management; contact your OpenAI\npartner representative to enable it. There is no delete API for\nOpenAI pixels. If the pixel is created but the Conversions API key\nprovisioning then fails, the pixel is left live on OpenAI (it cannot\nbe cleaned up) and the error message names the surviving pixel id and\nwarns against retrying, since a retry would create a second, orphaned\npixel.\n\nNOT idempotent on either platform: each call creates a new pixel (and,\nfor OpenAI, a new Conversions API key plus, with `defaultEventType`, a\nnew conversion event setting). Do not retry blindly on\ntimeout. Meta (platform `metaads`) and OpenAI Ads (platform\n`openaiads`); other platforms return 405.\n","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},"defaultEventType":{"type":"string","enum":["order_created","lead_created","items_added","contents_viewed","checkout_started","registration_completed","subscription_created","trial_started","appointment_scheduled","page_viewed","app_installed","app_opened"],"description":"OpenAI Ads only (ignored by Meta). When set, also provisions a standard conversion event setting wired to the new pixel, so `goal: conversions` ad creates on `POST /v1/ads/create` have an event to reference immediately."}}}}}},"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":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Platform does not support creating tracking tags."},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"422":{"description":"OpenAI Ads only: the ad account is not enabled for pixel management. Contact your OpenAI partner representative."},"502":{"description":"Meta was unreachable or returned an unclassified error (type: platform_error; the raw Meta payload is in platformError). Creating a pixel is NOT idempotent, so before retrying confirm with GET /v1/accounts/{accountId}/tracking-tags that no pixel was created."}}}},"/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,\n`lastFiredTime`, `ownerBusinessId`, `isUnavailable`, etc. Meta only\n(platform `metaads`); other platforms return 405. OpenAI Ads has no\nget-by-id endpoint, so it 405s here too. Use\n`GET /v1/accounts/{accountId}/tracking-tags` (list) instead.\n","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"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"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":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Platform does not support fetching a tracking tag."},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"502":{"description":"Meta was unreachable or returned an unclassified error (type: platform_error; the raw Meta payload is in platformError). Retryable."}}},"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),\n`enableAutomaticMatching`, `automaticMatchingFields`,\n`firstPartyCookieStatus`, `dataUseSetting`. At least one is required.\nReturns the re-fetched canonical tag. Meta only (platform `metaads`);\nother platforms return 405.\n\nThere is no DELETE: Meta has no API to delete a pixel. To stop using\none, unshare it from your ad accounts (`DELETE\n.../tracking-tags/{tagId}/shared-accounts`) or disable it in Events\nManager.\n","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\nterse codes: em=email, ph=phone, fn=first name, ln=last\nname, ge=gender, db=date of birth, ct=city, st=state,\nzp=zip.\n","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":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Platform does not support updating tracking tags."},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"502":{"description":"Meta was unreachable or returned an unclassified error (type: platform_error; the raw Meta payload is in platformError). Retryable."}}}},"/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"}}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"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":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Platform does not support shared accounts."},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"502":{"description":"Meta was unreachable or returned an unclassified error (type: platform_error; the raw Meta payload is in platformError). Retryable."}}},"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\naccount can use it. Requires that you administer both the pixel's owning\nBusiness Manager and the target ad account; a pixel on a personal\n(non-BM) ad account can't be shared (Meta will reject the call). Meta\nonly (platform `metaads`); other platforms return 405.\n","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":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Platform does not support shared accounts."},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"502":{"description":"Meta was unreachable or returned an unclassified error (type: platform_error; the raw Meta payload is in platformError). Retryable."}}},"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\nJSON body field for clients that can send DELETE bodies. Meta only\n(platform `metaads`); other platforms return 405.\n","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":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Platform does not support shared accounts."},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"502":{"description":"Meta was unreachable or returned an unclassified error (type: platform_error; the raw Meta payload is in platformError). Retryable."}}}},"/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`).\nRows are passed through from Meta as-is; their shape depends on the\n`aggregation` requested. Meta only (platform `metaads`); other platforms\nreturn 405.\n","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":{"$ref":"#/components/responses/AccountUnavailable"},"405":{"description":"Platform does not support tracking-tag stats."},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"502":{"description":"Meta was unreachable or returned an unclassified error (type: platform_error; the raw Meta payload is in platformError). Retryable."}}}},"/v1/accounts/{accountId}/blogs":{"get":{"x-resource-group":"publishing","operationId":"listBlogs","tags":["Blogs"],"x-platforms":["shopify"],"summary":"List blogs","description":"Lists the blogs on the connected store, newest-first as the platform\nreturns them. Cursor-paginated: pass `limit` (1-50, default 20) and the\n`cursor` from a previous response's `nextCursor`; `nextCursor` is null\nwhen there are no more pages.\n\nSupported on Shopify (platform `shopify`). Accounts on platforms\nwithout blogs support return 400; a blogs-capable platform that lacks\nthis specific operation returns 405.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"Connected Shopify SocialAccount id."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":50,"default":20},"description":"Page size (1-50)."},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Opaque cursor from a previous response. Omit for the first page."}],"responses":{"200":{"description":"Blogs listed","content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"type":"string","enum":["shopify"]},"blogs":{"type":"array","items":{"$ref":"#/components/schemas/Blog"}},"nextCursor":{"type":["string","null"],"description":"Cursor for the next page; null when there are no more pages."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"The platform rejected the request (code insufficient_permissions); reconnect the Shopify account to restore access."},"404":{"description":"Account not found or not accessible (code account_not_found)."},"405":{"description":"Platform does not support listing blogs."},"429":{"description":"Rate limited, either by Zernio or by Shopify. Retry later."}}},"post":{"x-resource-group":"publishing","operationId":"createBlog","tags":["Blogs"],"x-platforms":["shopify"],"summary":"Create a blog","description":"Creates a blog on the connected store. The platform generates the URL\n`handle` from the title when omitted.\n\nSupported on Shopify (platform `shopify`). Accounts on platforms\nwithout blogs support return 400; a blogs-capable platform that lacks\nthis specific operation returns 405.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"Connected Shopify SocialAccount id."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["title"],"properties":{"title":{"type":"string","minLength":1},"handle":{"type":"string","minLength":1,"description":"URL slug. Generated from the title when omitted."}}}}}},"responses":{"201":{"description":"Blog created","content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"type":"string","enum":["shopify"]},"blog":{"$ref":"#/components/schemas/Blog"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"The platform rejected the request (code insufficient_permissions); reconnect the Shopify account to restore access."},"404":{"description":"Account not found or not accessible (code account_not_found)."},"405":{"description":"Platform does not support creating blogs."},"429":{"description":"Rate limited, either by Zernio or by Shopify. Retry later."}}}},"/v1/accounts/{accountId}/blogs/{blogId}":{"get":{"x-resource-group":"publishing","operationId":"getBlog","tags":["Blogs"],"x-platforms":["shopify"],"summary":"Get a blog","description":"Fetches a single blog. `blogId` is the platform's numeric blog id from\n`GET /v1/accounts/{accountId}/blogs`, not a Zernio id.\n\nSupported on Shopify (platform `shopify`). Accounts on platforms\nwithout blogs support return 400; a blogs-capable platform that lacks\nthis specific operation returns 405.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"Connected Shopify SocialAccount id."},{"name":"blogId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Platform-native numeric blog id. Non-numeric values return 400."}],"responses":{"200":{"description":"Blog fetched","content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"type":"string","enum":["shopify"]},"blog":{"$ref":"#/components/schemas/Blog"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"The platform rejected the request (code insufficient_permissions); reconnect the Shopify account to restore access."},"404":{"description":"Account not found or not accessible (code account_not_found), or blog not found (code blog_not_found)."},"405":{"description":"Platform does not support fetching a blog."},"429":{"description":"Rate limited, either by Zernio or by Shopify. Retry later."}}},"patch":{"x-resource-group":"publishing","operationId":"updateBlog","tags":["Blogs"],"x-platforms":["shopify"],"summary":"Update a blog","description":"Partial-updates a blog. Send any subset of `title` and `handle`; at\nleast one field is required (an empty body returns 400).\n\nSupported on Shopify (platform `shopify`). Accounts on platforms\nwithout blogs support return 400; a blogs-capable platform that lacks\nthis specific operation returns 405.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"Connected Shopify SocialAccount id."},{"name":"blogId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Platform-native numeric blog id. Non-numeric values return 400."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"At least one field is required.","properties":{"title":{"type":"string","minLength":1},"handle":{"type":"string","minLength":1,"description":"URL slug. Changing it changes the blog URL on the store."}}}}}},"responses":{"200":{"description":"Blog updated","content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"type":"string","enum":["shopify"]},"blog":{"$ref":"#/components/schemas/Blog"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"The platform rejected the request (code insufficient_permissions); reconnect the Shopify account to restore access."},"404":{"description":"Account not found or not accessible (code account_not_found), or blog not found (code blog_not_found)."},"405":{"description":"Platform does not support updating a blog."},"429":{"description":"Rate limited, either by Zernio or by Shopify. Retry later."}}},"delete":{"x-resource-group":"publishing","operationId":"deleteBlog","tags":["Blogs"],"x-platforms":["shopify"],"summary":"Delete a blog","description":"Deletes the blog AND every article in it. The delete happens on the\nplatform and is permanent; Zernio stores nothing to restore it from.\n\nSupported on Shopify (platform `shopify`). Accounts on platforms\nwithout blogs support return 400; a blogs-capable platform that lacks\nthis specific operation returns 405.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"Connected Shopify SocialAccount id."},{"name":"blogId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Platform-native numeric blog id. Non-numeric values return 400."}],"responses":{"204":{"description":"Blog deleted (no content)."},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"The platform rejected the request (code insufficient_permissions); reconnect the Shopify account to restore access."},"404":{"description":"Account not found or not accessible (code account_not_found), or blog not found (code blog_not_found)."},"405":{"description":"Platform does not support deleting a blog."},"429":{"description":"Rate limited, either by Zernio or by Shopify. Retry later."}}}},"/v1/accounts/{accountId}/blogs/{blogId}/articles":{"get":{"x-resource-group":"publishing","operationId":"listBlogArticles","tags":["Blogs"],"x-platforms":["shopify"],"summary":"List blog articles","description":"Lists the articles of a blog. Cursor-paginated: pass `limit` (1-50,\ndefault 20) and the `cursor` from a previous response's `nextCursor`;\n`nextCursor` is null when there are no more pages.\n\nSupported on Shopify (platform `shopify`). Accounts on platforms\nwithout blogs support return 400; a blogs-capable platform that lacks\nthis specific operation returns 405.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"Connected Shopify SocialAccount id."},{"name":"blogId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Platform-native numeric blog id. Non-numeric values return 400."},{"name":"limit","in":"query","required":false,"schema":{"type":"integer","minimum":1,"maximum":50,"default":20},"description":"Page size (1-50)."},{"name":"cursor","in":"query","required":false,"schema":{"type":"string"},"description":"Opaque cursor from a previous response. Omit for the first page."}],"responses":{"200":{"description":"Articles listed","content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"type":"string","enum":["shopify"]},"articles":{"type":"array","items":{"$ref":"#/components/schemas/BlogArticle"}},"nextCursor":{"type":["string","null"],"description":"Cursor for the next page; null when there are no more pages."}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"The platform rejected the request (code insufficient_permissions); reconnect the Shopify account to restore access."},"404":{"description":"Account not found or not accessible (code account_not_found), or blog not found (code blog_not_found)."},"405":{"description":"Platform does not support listing articles."},"429":{"description":"Rate limited, either by Zernio or by Shopify. Retry later."}}},"post":{"x-resource-group":"publishing","operationId":"createBlogArticle","tags":["Blogs"],"x-platforms":["shopify"],"summary":"Create a blog article","description":"Creates an article on the blog. Publishing behavior:\n\n- `isPublished: false` keeps the article as a draft.\n- A future `publishDate` schedules publication natively on the\n  platform; the platform publishes it at that time with no Zernio\n  queue involved.\n- `seo.title` / `seo.description` map to Shopify's global `title_tag`\n  and `description_tag` metafields (the fields Shopify themes read for\n  the page title and meta description).\n\nSupported on Shopify (platform `shopify`). Accounts on platforms\nwithout blogs support return 400; a blogs-capable platform that lacks\nthis specific operation returns 405.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"Connected Shopify SocialAccount id."},{"name":"blogId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Platform-native numeric blog id. Non-numeric values return 400."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["title"],"properties":{"title":{"type":"string","minLength":1},"bodyHtml":{"type":"string","description":"Article body as HTML."},"handle":{"type":"string","description":"URL slug. Generated from the title when omitted."},"tags":{"type":"array","items":{"type":"string"}},"author":{"type":"string","description":"Display name of the article author."},"excerpt":{"type":"string","description":"Short summary shown in blog listings."},"image":{"type":"object","required":["url"],"description":"Featured image. The platform downloads it, so the URL must be publicly reachable.","properties":{"url":{"type":"string","format":"uri"},"altText":{"type":"string"}}},"seo":{"type":"object","description":"Search-engine overrides. Maps to Shopify global metafields (title_tag and description_tag).","properties":{"title":{"type":"string"},"description":{"type":"string"}}},"isPublished":{"type":"boolean","description":"Set false to create the article as a draft."},"publishDate":{"type":"string","format":"date-time","description":"ISO 8601 datetime with offset (or Z). A future date schedules publication natively on the platform."}}}}}},"responses":{"201":{"description":"Article created","content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"type":"string","enum":["shopify"]},"article":{"$ref":"#/components/schemas/BlogArticle"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"The platform rejected the request (code insufficient_permissions); reconnect the Shopify account to restore access."},"404":{"description":"Account not found or not accessible (code account_not_found), or blog not found (code blog_not_found)."},"405":{"description":"Platform does not support creating articles."},"429":{"description":"Rate limited, either by Zernio or by Shopify. Retry later."}}}},"/v1/accounts/{accountId}/blogs/{blogId}/articles/{articleId}":{"get":{"x-resource-group":"publishing","operationId":"getBlogArticle","tags":["Blogs"],"x-platforms":["shopify"],"summary":"Get a blog article","description":"Fetches a single article. An article addressed through a blog it does\nnot belong to is a 404 (code blog_article_not_found).\n\nSupported on Shopify (platform `shopify`). Accounts on platforms\nwithout blogs support return 400; a blogs-capable platform that lacks\nthis specific operation returns 405.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"Connected Shopify SocialAccount id."},{"name":"blogId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Platform-native numeric blog id. Non-numeric values return 400."},{"name":"articleId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Platform-native numeric article id. Non-numeric values return 400."}],"responses":{"200":{"description":"Article fetched","content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"type":"string","enum":["shopify"]},"article":{"$ref":"#/components/schemas/BlogArticle"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"The platform rejected the request (code insufficient_permissions); reconnect the Shopify account to restore access."},"404":{"description":"Account not found or not accessible (code account_not_found), blog not found (code blog_not_found), or article not found (code blog_article_not_found)."},"405":{"description":"Platform does not support fetching an article."},"429":{"description":"Rate limited, either by Zernio or by Shopify. Retry later."}}},"patch":{"x-resource-group":"publishing","operationId":"updateBlogArticle","tags":["Blogs"],"x-platforms":["shopify"],"summary":"Update a blog article","description":"Partial-updates an article. Send any subset of the create fields\n(`title`, `bodyHtml`, `handle`, `tags`, `author`, `excerpt`, `image`,\n`seo`, `isPublished`, `publishDate`); at least one field is required\n(an empty body returns 400). `isPublished` and `publishDate` behave as\non create: `isPublished: false` unpublishes back to a draft and a\nfuture `publishDate` schedules publication natively on the platform.\n\nSupported on Shopify (platform `shopify`). Accounts on platforms\nwithout blogs support return 400; a blogs-capable platform that lacks\nthis specific operation returns 405.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"Connected Shopify SocialAccount id."},{"name":"blogId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Platform-native numeric blog id. Non-numeric values return 400."},{"name":"articleId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Platform-native numeric article id. Non-numeric values return 400."}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","description":"At least one field is required.","properties":{"title":{"type":"string","minLength":1},"bodyHtml":{"type":"string","description":"Article body as HTML."},"handle":{"type":"string","description":"URL slug of the article."},"tags":{"type":"array","items":{"type":"string"},"description":"Replaces the full tag list."},"author":{"type":"string","description":"Display name of the article author."},"excerpt":{"type":"string","description":"Short summary shown in blog listings."},"image":{"type":"object","required":["url"],"description":"Featured image. The platform downloads it, so the URL must be publicly reachable.","properties":{"url":{"type":"string","format":"uri"},"altText":{"type":"string"}}},"seo":{"type":"object","description":"Search-engine overrides. Maps to Shopify global metafields (title_tag and description_tag).","properties":{"title":{"type":"string"},"description":{"type":"string"}}},"isPublished":{"type":"boolean","description":"Set false to unpublish the article back to a draft."},"publishDate":{"type":"string","format":"date-time","description":"ISO 8601 datetime with offset (or Z). A future date schedules publication natively on the platform."}}}}}},"responses":{"200":{"description":"Article updated","content":{"application/json":{"schema":{"type":"object","properties":{"platform":{"type":"string","enum":["shopify"]},"article":{"$ref":"#/components/schemas/BlogArticle"}}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"The platform rejected the request (code insufficient_permissions); reconnect the Shopify account to restore access."},"404":{"description":"Account not found or not accessible (code account_not_found), blog not found (code blog_not_found), or article not found (code blog_article_not_found)."},"405":{"description":"Platform does not support updating an article."},"429":{"description":"Rate limited, either by Zernio or by Shopify. Retry later."}}},"delete":{"x-resource-group":"publishing","operationId":"deleteBlogArticle","tags":["Blogs"],"x-platforms":["shopify"],"summary":"Delete a blog article","description":"Deletes the article. The delete happens on the platform and is\npermanent; Zernio stores nothing to restore it from.\n\nSupported on Shopify (platform `shopify`). Accounts on platforms\nwithout blogs support return 400; a blogs-capable platform that lacks\nthis specific operation returns 405.\n","security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"path","required":true,"schema":{"type":"string"},"description":"Connected Shopify SocialAccount id."},{"name":"blogId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Platform-native numeric blog id. Non-numeric values return 400."},{"name":"articleId","in":"path","required":true,"schema":{"type":"string","pattern":"^\\d+$"},"description":"Platform-native numeric article id. Non-numeric values return 400."}],"responses":{"204":{"description":"Article deleted (no content)."},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"The platform rejected the request (code insufficient_permissions); reconnect the Shopify account to restore access."},"404":{"description":"Account not found or not accessible (code account_not_found), blog not found (code blog_not_found), or article not found (code blog_article_not_found)."},"405":{"description":"Platform does not support deleting an article."},"429":{"description":"Rate limited, either by Zernio or by Shopify. Retry later."}}}},"/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\nits hash. Check the user-typed code with\nPOST /v1/verify/verifications/{verificationId}/check.\n\nRe-POSTing for the same (channel, to) while a verification is active\nRESENDS a fresh code on the existing verification (200 with\n`resend: true`) instead of creating a new one; resends are limited to\none per 60 seconds (429 with `retryAfterSeconds` inside the cooldown).\nThe stored brandName/codeLength/ttlMinutes win on a resend.\n\nCodes deliver by SMS from a phone number on your account (`from`\noptional when you own exactly one SMS-enabled number) and the message\nuses a fixed template. Each accepted send bills one verification fee\nplus the standard message rate.\n","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":{"200":{"description":"Active verification found: a fresh code was resent (`resend: true`).","content":{"application/json":{"schema":{"$ref":"#/components/schemas/Verification"}}}},"201":{"description":"Verification created and the code sent.","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\npast its expiry reads as `expired`). Verification records are deleted\n24 hours after creation, after which this returns 404.\n","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\nanswer 200 with `valid: false` and the settled `status`. Only an\nunknown id is a 404. A correct code consumes the verification\n(single-use, `status: approved`) and fires the `verification.approved`\nwebhook; the 5th wrong attempt settles it as `max_attempts_reached`\nand fires `verification.failed`.\n","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)."}}}},"/v1/ads/pixels":{"get":{"operationId":"listTikTokAdPixels","summary":"List TikTok ad pixels","description":"Lists pixels and their supported optimization events for a connected TikTok Ads account. The advertiser defaults to the first advertiser on the connection. Reconnect if Pixel Management permission has not been granted.","tags":["Ad Accounts"],"x-resource-group":"ads","x-platforms":["tiktok"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$"},"description":"Zernio SocialAccount ID."},{"name":"advertiserId","in":"query","schema":{"type":"string"},"description":"Advertiser belonging to this connection."},{"name":"code","in":"query","schema":{"type":"string"},"description":"Filter by a Pixel Code."}],"responses":{"200":{"description":"TikTok pixels.","content":{"application/json":{"schema":{"type":"object","properties":{"advertiserId":{"type":"string"},"pixels":{"type":"array","items":{"type":"object","properties":{"pixelId":{"type":"string"},"pixelCode":{"type":"string"},"name":{"type":"string"},"events":{"type":"array","items":{"type":"string"}},"eventDetails":{"type":"array","items":{"type":"object","properties":{"name":{"type":"string"},"optimizationEvent":{"type":["string","null"]},"custom":{"type":"boolean"}}}}}}}}},"example":{"advertiserId":"7330955083452284929","pixels":[]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access required."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"422":{"description":"Pixel Management permission is missing (code reconnect_required). Reconnect TikTok Ads to grant it."}}}},"/v1/ads/partnership-content":{"get":{"operationId":"listPartnershipAdContent","summary":"List partnership ad content","description":"Private beta. Lists creator Instagram posts available to the advertiser for Partnership Ads. Supply creatorUsername or postUrl. Requires instagram_branded_content_ads_brand permission and an advertiser Instagram Business Account.","tags":["Ad Creatives"],"x-resource-group":"ads","x-platforms":["meta"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$"},"description":"Zernio SocialAccount ID."},{"name":"creatorUsername","in":"query","schema":{"type":"string"},"description":"Creator username. Required unless postUrl is supplied."},{"name":"postUrl","in":"query","schema":{"type":"string","format":"uri"},"description":"Instagram post permalink. Required unless creatorUsername is supplied."},{"name":"onlyAllowlisted","in":"query","schema":{"type":"boolean"},"description":"Return only creators with account-level permission."}],"responses":{"200":{"description":"Advertisable Instagram media.","content":{"application/json":{"schema":{"type":"object","properties":{"media":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"permalink":{"type":"string"},"ownerId":{"type":"string"},"hasPermissionForPartnershipAd":{"type":"boolean"},"isCreatorAllowlisted":{"type":"boolean"},"eligibilityErrors":{"type":"array","items":{"type":"string"}},"recommendedCampaignObjectives":{"type":"array","items":{"type":"string"}}}}}}},"example":{"media":[]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access required. Partnership operations also require private beta access."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"422":{"description":"The advertiser Instagram Business Account could not be resolved."}}}},"/v1/ads/partnership-permissions":{"get":{"operationId":"listPartnershipAdPermissions","summary":"List partnership permissions","description":"Private beta. Lists granted or pending creator permissions for the advertiser Instagram Business Account. Requires instagram_branded_content_ads_brand permission.","tags":["Ad Creatives"],"x-resource-group":"ads","x-platforms":["meta"],"security":[{"bearerAuth":[]}],"parameters":[{"name":"accountId","in":"query","required":true,"schema":{"type":"string","pattern":"^[a-fA-F0-9]{24}$"},"description":"Zernio SocialAccount ID."},{"name":"creatorUsername","in":"query","schema":{"type":"string"},"description":"Filter by creator username."}],"responses":{"200":{"description":"Partnership permissions.","content":{"application/json":{"schema":{"type":"object","properties":{"permissions":{"type":"array","items":{"type":"object","properties":{"id":{"type":"string"},"permissionType":{"type":"string"},"status":{"type":"string"}}}}}},"example":{"permissions":[]}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access required. Partnership operations also require private beta access."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"422":{"description":"The advertiser Instagram Business Account could not be resolved."}}},"post":{"operationId":"setPartnershipAdPermission","summary":"Set partnership permission","description":"Private beta. Requests permission from a creator or revokes it when revoke is true. Requests require the creator to approve in Instagram. Requires instagram_branded_content_ads_brand permission.","tags":["Ad Creatives"],"x-resource-group":"ads","x-platforms":["meta"],"security":[{"bearerAuth":[]}],"requestBody":{"required":true,"content":{"application/json":{"schema":{"type":"object","required":["accountId","creatorUsername"],"properties":{"accountId":{"type":"string","pattern":"^[a-fA-F0-9]{24}$"},"creatorUsername":{"type":"string","minLength":1},"revoke":{"type":"boolean"}}},"example":{"accountId":"507f1f77bcf86cd799439011","creatorUsername":"example_creator","revoke":false}}}},"responses":{"200":{"description":"Partnership permission state.","content":{"application/json":{"schema":{"type":"object","properties":{"permission":{"type":"object","properties":{"id":{"type":"string"},"permissionType":{"type":"string"},"status":{"type":"string"}}}}},"example":{"permission":{"id":"123456789","permissionType":"AD","status":"PENDING"}}}}},"201":{"description":"Partnership permission state.","content":{"application/json":{"schema":{"type":"object","properties":{"permission":{"type":"object","properties":{"id":{"type":"string"},"permissionType":{"type":"string"},"status":{"type":"string"}}}}},"example":{"permission":{"id":"123456789","permissionType":"AD","status":"PENDING"}}}}},"400":{"$ref":"#/components/responses/BadRequest"},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Ads access required. Partnership operations also require private beta access."},"404":{"$ref":"#/components/responses/AccountUnavailable"},"409":{"$ref":"#/components/responses/AccountConnectionRequired"},"422":{"description":"The advertiser Instagram Business Account could not be resolved."}}}},"/v1/tools/tiktok/download":{"get":{"operationId":"downloadTikTokVideo","tags":["Tools"],"x-platforms":["tiktok"],"summary":"Download a TikTok video","description":"Get a download URL or list available formats for a TikTok video. Requires Tools API access and uses the Tools API rate limit. Provider gateway failures and provider-side access blocks return 503; an unavailable video returns 404.","parameters":[{"name":"url","in":"query","required":true,"schema":{"type":"string"},"description":"TikTok video URL or numeric video ID.","example":"https://www.tiktok.com/@example/video/7412345678901234567"},{"name":"action","in":"query","schema":{"type":"string","enum":["download","formats"],"default":"download"},"description":"Return a download URL or the available formats."},{"name":"formatId","in":"query","schema":{"type":"string"},"description":"Format ID from the formats response. Omit to select the first available format."}],"responses":{"200":{"description":"Download URL or available formats.","content":{"application/json":{"schema":{"type":"object","required":["success","title"],"properties":{"success":{"type":"boolean"},"title":{"type":"string"},"format":{"type":"string","description":"Selected format label for action=download."},"downloadUrl":{"type":"string","description":"Media download URL for action=download."},"duration":{"type":"number","description":"Duration in seconds for action=formats."},"cover":{"type":"string","description":"Thumbnail URL for action=formats."},"formats":{"type":"array","description":"Available formats for action=formats.","items":{"type":"object","properties":{"id":{"type":"string"},"label":{"type":"string"},"ext":{"type":"string"},"type":{"type":"string"},"height":{"type":"number"},"width":{"type":"number"},"fps":{"type":"number"},"hasAudio":{"type":"boolean"},"hasVideo":{"type":"boolean"}}}}}},"example":{"success":true,"title":"TikTok by @example","format":"MP4 • No watermark","downloadUrl":"https://example.com/video.mp4"}}}},"400":{"description":"Missing or invalid url, action, or formatId."},"401":{"$ref":"#/components/responses/Unauthorized"},"403":{"description":"Tools API access is required."},"404":{"description":"The video or a downloadable format was not found."},"429":{"$ref":"#/components/responses/RateLimited"},"502":{"$ref":"#/components/responses/UpstreamFailure"},"503":{"$ref":"#/components/responses/TemporarilyUnavailable"}}}}}}