openapi: 3.1.0
info:
title: Zernio API
version: "1.0.4"
description: |
API reference for Zernio. Authenticate with a Bearer API key.
Base URL: https://zernio.com/api
Versioning and deprecation: all endpoints are versioned in the URL path
(current version: /v1). Breaking changes only ship in a new path version;
existing versions keep working. Deprecated operations are marked
'deprecated: true' in this spec and announced in the changelog
(https://zernio.com/changelog) before removal.
Errors: every 4xx/5xx response is application/json with a machine-readable
'code' and a human-readable 'error' message (see the ErrorResponse schema).
termsOfService: https://zernio.com/tos
contact:
name: Zernio Support
url: https://zernio.com
email: support@zernio.com
# RapidAPI extensions for Hub listing
x-logo:
url: https://zernio.com/icon.png?v=3
x-long-description: |
Zernio is the social media API that replaces 16 integrations. Schedule posts, retrieve analytics,
manage DMs, comments, and reviews across X, Instagram, WhatsApp, TikTok, LinkedIn,
Facebook, YouTube, Threads, Reddit, Pinterest, Bluesky, Telegram, Google Business Profile, Snapchat,
Discord, and Slack, all from a single REST API. Run paid ads on Meta (Facebook + Instagram), Google,
TikTok, LinkedIn, Pinterest, X, and OpenAI from the same account.
Key features: Unified posting to 16 platforms, ads management on 7 ad networks (via /v1/ads), aggregated analytics, unified inbox (DMs, comments, reviews), webhooks, OAuth connect, queue scheduling, and white-label support for agencies managing unlimited accounts.
Supported posting platforms: 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.
x-category: Social
x-website: https://zernio.com
x-thumbnail: https://rapidapi-prod-apis.s3.amazonaws.com/b24d3df5-563c-4a50-9e1e-1ad3eb1fce69.png
x-version-lifecycle: ACTIVE
x-badges:
- name: "social media"
value: "social media"
- name: "scheduling"
value: "scheduling"
- name: "instagram"
value: "instagram"
- name: "tiktok"
value: "tiktok"
- name: "twitter"
value: "twitter"
- name: "linkedin"
value: "linkedin"
- name: "facebook"
value: "facebook"
- name: "youtube"
value: "youtube"
- name: "social media api"
value: "social media api"
- name: "posting"
value: "posting"
# RapidAPI Hub documentation tab (README)
x-documentation:
readme: |
# Zernio API
The social media API that replaces 14 integrations. Build social media features into your app in minutes, not months.
## Quick Start
**Base URL:** `https://zernio.com/api/v1`
**Authentication:** All requests require a Bearer API key in the `Authorization` header.
```bash
curl https://zernio.com/api/v1/user \
-H "Authorization: Bearer YOUR_API_KEY"
```
Get your API key at [zernio.com/dashboard/api-keys](https://zernio.com/dashboard/api-keys).
## Core Concepts
| Concept | Description |
|---------|-------------|
| **Profiles** | Containers that organize accounts into brands or projects |
| **Accounts** | Connected accounts belonging to a profile |
| **Posts** | Content scheduled or published to one or more accounts |
| **Queue** | Recurring time slots for automatic post scheduling |
## Create a Post
```bash
curl -X POST https://zernio.com/api/v1/posts \
-H "Authorization: Bearer $ZERNIO_API_KEY" \
-H "Content-Type: application/json" \
-d '{
"content": "Hello from the Zernio API.",
"scheduledFor": "2027-01-15T10:00:00Z",
"platforms": [
{ "platform": "twitter", "accountId": "64e1f0a9e2b5af0012ab34cd" },
{ "platform": "linkedin", "accountId": "66b2e19d8c3f5a7e9d0b1c2d" }
]
}'
```
This single call publishes or schedules the post to every account in `platforms`, across any platform. Send `publishNow: true` instead of `scheduledFor` to publish immediately.
## Supported Platforms
| Platform | Post | Stories/Reels | Analytics | Inbox |
|----------|------|---------------|-----------|-------|
| X | Yes | - | Yes | Yes |
| Instagram | Yes | Yes | Yes | Yes |
| Facebook | Yes | Stories | Yes | Yes |
| LinkedIn | Yes | - | Partial | - |
| TikTok | Yes | - | Yes | Comments |
| YouTube | Yes | Shorts | Yes | Yes |
| Pinterest | Yes | - | Yes | - |
| Reddit | Yes | - | Partial | Yes |
| Bluesky | Yes | - | Partial | Yes |
| Threads | Yes | - | Yes | Yes |
| Google Business Profile | Yes | - | - | Yes |
| Telegram | Yes | - | - | - |
| Snapchat | Yes | - | - | - |
> **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.
> **LinkedIn Analytics Note:** For personal LinkedIn accounts, analytics are only available for posts published through Zernio. This is a LinkedIn API limitation: the `memberCreatorPostAnalytics` endpoint only returns metrics for posts authored by the authenticated user. Company/organization page analytics are not affected and work for all posts.
> **Google Business 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.
> **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.
## Rate Limits
API request throughput is rate-limited per minute on a sliding window. Limits scale with your team's total connected accounts:
- **0 to 2 accounts** (free tier): 60 req/min
- **3 to 2,000 accounts**: 600 req/min
- **2,001+ accounts**: 1,200 req/min
Legacy AppSumo lifetime tiers get a flat 600 req/min regardless of tier.
Posts themselves are unlimited on every connected 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.
TikTok 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.
All responses include `X-RateLimit-Limit`, `X-RateLimit-Remaining`, and `X-RateLimit-Reset` headers. Read these instead of hard-coding limits, since your tier may be configured higher than the default.
## Webhooks
Receive real-time notifications for post status changes, account events, and incoming messages:
- `post.scheduled` - Post entered the scheduled state: created with a schedule, added to a queue, a draft promoted to scheduled or queued, a failed post retried, or a recycled clone created
- `post.published` - Post successfully published. 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.
- `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.
- `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.
- `post.cancelled` - Post publishing was cancelled
- `post.recycled` - Post recycled (cloned and re-scheduled)
- `post.platform.published` - A single platform target inside a post finished publishing successfully. Fires once per platform-account as it terminates, without waiting for other platforms on the same post. Use this for incremental UIs; use `post.published` for the post-level rollup. 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.
- `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.
- `post.platform.deleted` - A single platform target inside a post was detected as deleted on the platform after publishing (e.g. the user deleted the Instagram post natively). Detection is poll-driven via Zernio background sync (~hourly), not real-time. `platform.deletedAt` carries the detection time. Coverage is bounded to the posts the platform listing returns.
- `post.tiktok.url_resolved` - A published TikTok post's public URL became available. TikTok exposes the numeric video id asynchronously, so `post.published` / `post.platform.published` can carry an empty `publishedUrl` for TikTok; this event delivers the URL once resolved (at most once per platform target). Never fires for drafts or private posts, which have no public URL.
- `post.external.created` - A native post authored outside Zernio (e.g. a Google Business Profile localPost created in the Google UI) was detected by our sync for the first time. Poll-driven (~hourly), not real-time. Payload carries `post.source: "external"`.
- `post.external.updated` - A tracked native post's text or media changed on the platform. Edits are detected by comparing text/media structure and (where the platform exposes it) the platform's own edit timestamp; media-URL-only refreshes do not fire this.
- `post.external.deleted` - A tracked native post was detected as removed from the platform. `post.deletedAt` carries the detection time. Coverage is bounded to the most recent posts the platform listing returns, 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.
- `account.connected` - Account connected
- `account.disconnected` - Account disconnected (token expired)
- `account.ads.initial_sync_completed` - Initial ads sync (discovery + 90-day backfill) completed for an ads-enabled account
- `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.
- `message.received` - New inbound DM or SMS received (SMS arrives with `platform: "sms"`)
- `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
- `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
- `message.sent` - DM sent via the API
- `message.edited` - A sender edited a message (Instagram, Messenger, Telegram, WhatsApp)
- `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.
- `message.delivered` - An outgoing message was delivered (WhatsApp, Messenger, SMS)
- `message.read` - An outgoing message was read by the recipient (WhatsApp, Messenger, Instagram)
- `message.failed` - An outgoing message failed delivery (WhatsApp, SMS; on SMS the carrier's error code rides in `error`)
- `reaction.received` - A participant added or removed an emoji reaction on a message (WhatsApp, Telegram, Slack, Instagram, Facebook Messenger)
- `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)
- `comment.received` - New comment received on a post (Instagram, Facebook, Threads, YouTube, LinkedIn, Bluesky, Reddit, TikTok). X/Twitter does NOT fire this event
- `review.new` - New review posted on a connected account (Google Business Profile)
- `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.
- `lead.received` - New lead submitted against a Meta Lead Gen (Instant) Form. `lead.fields` is the question-key → answer map; `lead.formId`/`lead.adId` give provenance.
- `ad.status_changed` - Ad, ad set, or campaign changed status on the ad platform (Meta: `in_process_ad_objects` entry/exit and `with_issues_ad_objects` violations)
- `whatsapp.template.status_updated` - WhatsApp Business template completed (re)review by Meta. `template.status` carries the new state (APPROVED, REJECTED, PENDING, PAUSED, DISABLED, IN_APPEAL, PENDING_DELETION); `template.reason` is Meta's free-form reason or "NONE".
- `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.
- `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.
- `call.received` - An inbound call (phone/PSTN or WhatsApp) reached one of your numbers and was routed to its destination
- `call.ended` - A call ended; carries duration, end reason, cost breakdown, and the recording when enabled
- `call.failed` - A call failed with a hard error before or during bridging
- `call.permission_request` - A WhatsApp user accepted or rejected your call-permission request
- `whatsapp.automatic_event` - Meta's automatic event identification detected a lead or purchase in a Click-to-WhatsApp conversation. Branch on `eventName` (`LeadSubmitted` | `Purchase`); carries the `ctwa_clid` Conversions API match key
- `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).
- `whatsapp.number.kyc_submitted` - An end customer completed a hosted KYC share link; the number entered regulatory review under your account
- `whatsapp.number.activated` - A provisioned WhatsApp number finished setup and is ready to connect
- `whatsapp.number.declined` - A regulated number order was declined in review; nothing activates and nothing is billed
- `whatsapp.number.action_required` - The regulator asked for more information on a placed number order; the order stays pending until provided
- `whatsapp.number.verification_required` - A regulated number needs end-user ID verification; carries the link to forward
- `whatsapp.number.suspended` - An active number was suspended (e.g. failed payment); carries a `reason`
- `whatsapp.number.reactivated` - A suspended number is usable again
- `whatsapp.number.released` - A number was released and is no longer usable (terminal); carries a `reason`
- `verification.approved` - A managed-OTP verification was approved (the user submitted the correct code)
- `verification.failed` - A managed-OTP verification was exhausted after too many wrong code attempts
- `webhook.test` - Test event sent when verifying a webhook endpoint
Every 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.
## Full Documentation
For complete guides, platform-specific details, and SDK references, visit [docs.zernio.com](https://docs.zernio.com).
## SDKs
Official SDKs available for: [Node.js](https://www.npmjs.com/package/@zernio/node), [Python](https://pypi.org/project/zernio-sdk), Go, Ruby, Java, PHP, .NET, and Rust.
servers:
- url: https://zernio.com/api
description: Production
- url: http://localhost:3000/api
description: Local
tags:
- name: Tools
description: "Media tools for authenticated API consumers."
- name: Posts
description: |
Create, schedule, list, update, and delete posts across all connected accounts.
- name: Blogs
description: |
Manage blogs and blog articles on connected accounts. Currently supported
for Shopify accounts (scopes read_content and write_content). Blogs are
the containers; articles are the posts inside them. All content lives on
the platform; Zernio proxies it and stores nothing.
- name: Users
description: |
Read the authenticated user and team members.
- name: Usage
description: |
Usage and metering. `GET /v1/usage` is dual-mode: bare calls return the
plan / quota snapshot (back-compat, same as `/v1/usage-stats`); calls
with `range` / `granularity` params return usage METERING: billed spend
(USD) by product family (accounts, numbers, calls, sms, dlc, xApi,
credits) over any window, from the usage-based invoice breakdown (also served
at `GET /v1/usage/daily`). The `/v1/usage/<domain>` spokes (calls, sms)
meter per-domain consumption volumes over a window. `GET /v1/billing` is
the billing statement (balance, credits, caps, payment status).
- name: Profiles
description: |
Manage profiles (named groups of accounts).
- name: Accounts
description: |
Manage connected accounts: list, fetch, update, disconnect, and read account health.
- name: Account Groups
description: |
Manage account groups (collections of accounts used for cross-posting and organization).
- name: API Keys
description: |
Create, list, and revoke API keys used to authenticate requests.
Resource groups. A key can opt out of any of the ten groups (publishing, engagement, messages, contacts, analytics, ads, telephony, accounts, billing, webhooks) via `disabledResourceGroups` at creation. Omit the field for a legacy full-access key. A key with any group disabled mints with the `zrk_` prefix, is refused on operations in those groups with 403 `insufficient_permissions` plus `required_group`, and can never manage API keys, invites, connected apps, or member identity. Each operation publishes its group as `x-resource-group`. There is no update endpoint: duplicate the key with different groups and revoke the old one.
- name: Connected Apps
description: |
List and revoke the OAuth clients (AI assistants and MCP connectors) authorized
on the account.
Connector tokens resolve against the same resource-group registry as API keys, but there is no way to narrow a connector's groups yet. Treat an authorized connector as full account access and revoke it if that is not what you want.
- name: Invites
description: |
Generate invite tokens for adding members to a team.
- name: Connect
description: |
OAuth and credential flows for connecting accounts, plus per-platform selection
steps (Facebook pages, Pinterest boards, LinkedIn organizations, Google Business Profile locations, etc.).
- name: Media
description: |
Upload and presign media (images, videos, documents) for use in posts.
- name: Reddit Search
description: |
Search Reddit posts and browse subreddit feeds.
- name: GMB Reviews
description: |
Google Business Profile reviews: list reviews and reply to them.
- name: GMB Food Menus
description: |
Read and update Google Business Profile food menus.
- name: GMB Location Details
description: |
Read and update Google Business Profile location details.
- name: GMB Media
description: |
Manage Google Business Profile media (photos and videos): list, upload, and delete.
- name: GMB Attributes
description: |
Read and update Google Business Profile attributes.
- name: GMB Place Actions
description: |
Manage Google Business Profile place action links (booking, ordering, reservations, etc.).
- name: Discord
description: |
Discord-specific endpoints for managing webhook identity (display name and avatar), switching channels, and listing guild channels.
- name: LinkedIn Mentions
description: |
Resolve LinkedIn organization and person mentions for use in posts.
- name: Instagram
description: |
Instagram-specific read endpoints: list a connected account's Stories and fetch
per-Story insights. All endpoints require an accountId parameter identifying the
Instagram-connected account.
- name: Queue
description: |
Manage posting-queue time slots and preview the upcoming queue.
- name: Analytics
description: |
Post and account analytics across platforms (insights, demographics, follower history,
best time to post, content decay, and aggregated metrics).
- name: Inbox Access
description: |
Check and manage inbox feature access.
- name: Messages
description: |
Unified inbox API for managing conversations and direct messages across all connected accounts.
All endpoints aggregate data from multiple accounts in a single API call.
Requires Inbox addon.
- name: Comments
description: |
Unified inbox API for managing comments on posts across all connected accounts.
Supports commenting on third-party posts for platforms that allow it (YouTube, X, Reddit, Bluesky, Threads).
All endpoints aggregate data from multiple accounts in a single API call.
Requires Inbox addon.
- name: Reviews
description: |
Unified inbox API for managing reviews on Facebook Pages and Google Business Profile accounts.
All endpoints aggregate data from multiple accounts in a single API call.
Requires Inbox addon.
- name: Mentions
description: |
Unified inbox API for managing mentions across connected accounts.
Currently supports LinkedIn organization mentions.
Requires Inbox addon.
- name: Twitter Engagement
description: |
X-specific engagement endpoints for retweeting, bookmarking, and following.
Rate limits: 50 requests per 15-min window per user. Retweets share the 300/3hr creation limit with tweet creation.
- name: Validate
description: |
Pre-flight validation endpoints. Check post content, character limits, media URLs, and subreddit existence before publishing.
- name: Account Settings
description: |
Platform-specific account settings: Facebook persistent menu, Instagram ice breakers, and Telegram bot commands.
- name: Contacts
description: |
Cross-platform contact management (CRM). Contacts are unified identities linked to platform-specific
channels (phone, IGSID, etc.). Created automatically when messages arrive, or manually via API.
- name: Custom Fields
description: |
Custom field definitions for contacts. Define fields (text, number, date, boolean, select) that can be
set on any contact for segmentation and personalization.
- name: Broadcasts
description: |
Platform-agnostic broadcast campaigns. Send bulk messages to contacts via any inbox platform.
WhatsApp broadcasts use templates; other platforms use generic messages.
- name: Sequences
description: |
Drip campaign sequences. Send a series of messages to enrolled contacts with configurable delays
between steps. Supports auto-exit on reply or unsubscribe.
- name: Workflows
description: |
Branching conversation automations. An inbound message matches a workflow's trigger and walks a
directed graph of nodes (send message, wait for reply, condition, set variable, delay, webhook,
handoff, end). Unlike Sequences (linear, time-based drips), Workflows are event-driven and
interactive. Fully supported on WhatsApp, Instagram, and Messenger; `send_message` template and
interactive modes are WhatsApp-only.
- name: Comment Automations
description: |
Comment-to-DM growth automations. Set up keyword triggers on Instagram/Facebook so
commenters automatically receive a DM. Scope per post or account-wide (omit
`platformPostId` to match comments on every post on the account, with unlimited
automations stacked per account). Supports dedup, optional public comment reply, and
auto-creates contacts.
- name: Ad Campaigns
description: |
The advertising structure: campaigns, ad sets, and ads. Create standalone campaigns/ads, boost an
organic post, duplicate at any level, pause/resume, and read the full tree and timeline. Campaigns
are virtual aggregations of ads grouped by their platform campaign ID.
Per-operation platform support is shown by the platform badges. Requires the Ads add-on.
- name: Ad Creatives
description: |
Creative assets: the standalone creative library (create/reuse/rename), the ad-account image library
(list + base64 upload), rendered ad previews, and product catalogs for Advantage+/dynamic ads. Meta only.
Requires the Ads add-on.
- name: Ad Audiences
description: |
Custom audiences for targeting: customer lists (hashed upload), website + engagement + lookalike
audiences, and reusable saved-targeting presets. Creation support varies by platform (badges show which);
LinkedIn adds company-list and engagement segments.
Requires the Ads add-on.
- name: Ad Targeting
description: |
Targeting discovery: search interests/behaviors/geo/demographics, estimate reach, and (LinkedIn)
bid pricing and supply forecasts. Feeds the targeting fields on create.
Requires the Ads add-on.
- name: Ad Library
description: |
Competitor and market research over the public ad archives (Meta Ad Library, LinkedIn Ad Library),
searched with the customer's own connected token. Requires the Ads add-on.
- name: Ad Insights
description: |
Measurement: cached aggregate analytics per ad/campaign, plus live Meta Graph insight queries
(arbitrary fields, breakdowns, filtering, attribution windows) and async report runs.
Requires the Ads add-on.
- name: Conversions
description: |
Server-side Conversions API: send + adjust conversion events (with hashed matching and consent/LDU
forwarding), read Event Match Quality, and manage conversion destinations (pixels/datasets) and their
ad-account associations. Supported on Meta, Google, TikTok and LinkedIn (badges show per operation).
Requires the Ads add-on.
- name: Messaging Ads
description: |
Click-to-message and click-to-call destination ads: WhatsApp (CTWA), Messenger, Instagram Direct,
and Call ads. Meta only.
Requires the Ads add-on.
- name: Reach and Frequency
description: |
Fixed-price reserved (Reach & Frequency) buying: quote a prediction, reserve price + inventory, and
buy via a RESERVED campaign. Meta only.
Requires the Ads add-on.
- name: Lead Gen
description: |
Instant lead forms on Facebook Pages: create/list/archive forms and retrieve (or test) their leads.
Meta only.
Requires the Ads add-on.
- name: Ad Accounts
description: |
Ad accounts and operational/diagnostic reads: list accounts, account finances, change/audit log,
A/B studies, high-demand periods, ad labels, DSA defaults + recommendations, and Business Managers
(Meta) / Business Centers (TikTok).
Requires the Ads add-on.
- name: Tracking Tags
description: |
Manage the platform measurement tag: the thing you create, install on a website, send events to,
and target ads against. On Meta this is a Pixel; the surface is platform-neutral so other platforms
(Pinterest Tag, LinkedIn Insight Tag, etc.) can be added without changing the contract. Create a tag,
get it (including the install code snippet), rename + adjust matching/cookie/data-use settings, share
it with ad accounts, and read aggregated event stats. Currently Meta-only.
Requires the Ads add-on.
- name: Business Agent
description: |
Provision and operate Meta Business Agent, Meta's own AI agent, on a connected WhatsApp number
without the merchant opening Business Manager. Check eligibility and setup state, create the agent,
turn it on or off, feed it knowledge (business information, FAQs, websites, files), give it skills
and UI skills, wire connectors and tools to your own APIs, cap usage with budgets, test it in Meta's
sandbox, send it business events, and run evaluations.
Two steps stay manual by Meta's design and have no API: the merchant accepts the Meta Business Agent
terms in WhatsApp Manager (until then every call answers `business_agent_terms_not_accepted`), and a
payment method is attached to the Business Agent billable account in Billing Hub (without it the agent
is on but Meta delivers no messages; allowlisted-only testing is exempt). `GET .../business-agent`
reports both.
While the agent answers a conversation your inbox receives it on the standby path and a reply from
Zernio takes control back. Every call needs the WhatsApp social account id, the Inbox add-on, and a
number managed through the Cloud API (a coexistence number that still runs the in-app Business AI is
not eligible).
- name: Webhooks
description: |
Configure webhooks for real-time notifications. Webhooks can be created from the dashboard (Settings → Webhooks) or via this API.
Events: post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled, post.platform.published, post.platform.failed, post.platform.deleted, post.tiktok.url_resolved, post.external.created, post.external.updated, post.external.deleted, account.connected, account.disconnected, account.ads.initial_sync_completed, 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.
Headers: 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.
Resource groups. Every event belongs to one group: post.* to publishing, message.*, reaction.*, conversation.*, call.* and whatsapp.automatic_event to messages, comment.* and review.* to engagement, lead.* to contacts, ad.* to ads, account.*, 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.
- name: Webhook Events
description: |
Incoming webhook deliveries sent by Zernio to your configured endpoint URL.
- name: Logs
description: |
Publishing logs for transparency and debugging. Each log includes the platform API endpoint, HTTP status code, request/response bodies, duration, and retry attempts. Logs are automatically deleted after 7 days.
- name: WhatsApp
description: |
WhatsApp Business API. Template, business profile, and phone number endpoints.
All endpoints require an accountId parameter identifying the WhatsApp-connected account.
- name: WhatsApp Calling
description: |
Voice calling over the WhatsApp Business API: enable/disable calling on a number,
configure call hours and permissions, and place or list calls.
All endpoints require an accountId parameter identifying the WhatsApp-connected account.
- name: WhatsApp Templates
description: |
Browse Meta's pre-approved WhatsApp template library. Use these read-only lookups to
discover library templates you can import as your own message templates.
All endpoints require an accountId parameter identifying the WhatsApp-connected account.
- name: WhatsApp Flows
description: |
WhatsApp Flows let you build native interactive forms, surveys, and booking experiences inside WhatsApp.
Flows are created in DRAFT status, populated with a Flow JSON definition, then published for sending.
Published flows are immutable; to update, create a new flow (optionally cloning the old one).
All endpoints require an accountId parameter identifying the WhatsApp-connected account.
- name: Phone Numbers
description: |
Buy and manage phone numbers. A phone number is a platform-independent unit;
WhatsApp, SMS, and Voice are features you enable on it. Purchase, search
inventory, complete country KYC, and release numbers here; turn features on
via the per-number sub-resources (`/v1/phone-numbers/{id}/voice`, `/sms`,
`/whatsapp/...`). Requires a paid plan.
- name: Verify
description: |
Managed one-time passcodes (OTP) for phone verification. Two calls
handle the whole lifecycle: create a verification (we generate the
code, deliver it by SMS from a phone number on your account, and store
only its hash) and check the code the user typed. Usage-based billing
only.
- name: WhatsApp Phone Numbers
description: |
Deprecated namespace: phone numbers moved to `/v1/phone-numbers` (a number is
platform-independent; WhatsApp is one feature on it). These aliases keep
working with the same contracts, but new integrations should use the
Phone Numbers endpoints.
- name: WhatsApp Sandbox
description: |
Shared WhatsApp sandbox: a Zernio-owned WhatsApp number every user can test against
without provisioning their own. Send the verified sandbox template to phones you
activate via a reply-based verification flow. Designed for testing message flows,
bot replies, and webhook payloads end-to-end with zero number-purchase overhead.
- name: Calls
description: |
Unified call history across every number you own: WhatsApp Business Calling and
regular phone (PSTN) calls in one list, newest first, without fanning out one
request per number. Each row carries `channel` and `accountId` so you can fetch
details and recordings from the matching channel-specific endpoint.
Private beta: returns 403 unless your account is enrolled.
- name: Voice
description: |
Regular phone (PSTN) calling on your numbers. Enable voice on a number and route
inbound calls to your own AI voice agent (Vapi/Retell), a phone, or a SIP endpoint,
with voicemail, business-hours routing, IVR, recording, and transcription. Place
outbound calls, manage live ones (end, blind-transfer), resolve recordings, and
drive the browser softphone.
Private beta: returns 403 unless your account is enrolled.
- name: SMS
description: |
SMS/MMS on your numbers: enable SMS on a number, send messages, validate recipient
numbers, export STOP opt-outs, and complete the US carrier registration (10DLC or
toll-free) required before US traffic delivers.
Private beta: returns 404 unless your account is enrolled.
components:
securitySchemes:
bearerAuth:
type: http
scheme: bearer
bearerFormat: JWT
description: "API key authentication: 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.
Automatically generated when initiating OAuth without a browser session.
Valid for 15 minutes. Used to authenticate Facebook page selection API calls.
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.
Reddit rate-limits per connected Reddit user (1000 requests per
10-minute window), and that budget is shared by every operation using
that account. Retry after the window resets rather than retrying
immediately; repeated calls while exhausted do not succeed and keep the
budget spent.
headers:
Retry-After:
description: 'Seconds remaining until the upstream quota resets.'
schema: { type: integer }
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
PaymentRequired:
description: |
Payment method or enterprise contract required. The authenticated
account hit a billing gate before the connection could proceed.
Three reasons:
- `free_tier_exceeded`: the team has connected more accounts
than the free tier allows. Add a payment method on the
dashboard to continue (the user will be billed per
additional connected account).
- `twitter_passthrough`: connecting an X account
requires a card on file from day one because X API calls
incur real per-call pass-through costs. Applies to the 1st
X account, not only the 3rd+.
- `enterprise_required`: the team is on an enterprise
contract with a negotiated connected-account cap and has
reached it. Self-service teams have NO connected-account cap (the
$1/account rate continues at any scale), so this reason can
only fire for teams whose contract sets an explicit limit.
`dashboard_url` deep-links to the enterprise contact page
rather than the billing tab. The end-user already has a
card on file; this gate is about contract terms, not card
collection.
SDK consumers should switch on `reason` to render the right
prompt. For `free_tier_exceeded` and `twitter_passthrough`,
redirect the end-user to `dashboard_url` to add a payment method
via Zernio's hosted Stripe Setup Checkout. For
`enterprise_required`, redirect to `dashboard_url` (the
enterprise contact form) to adjust the contract's limit.
content:
application/json:
schema:
type: object
required: [error, code, reason]
properties:
error:
type: string
description: Human-readable error message suitable for end-user display.
example: 'X (Twitter) requires a payment method due to API pass-through costs. Add a payment method to connect an X account.'
code:
type: string
enum: [PAYMENT_REQUIRED]
description: Machine-readable error code. Stable across versions.
reason:
type: string
enum: [free_tier_exceeded, twitter_passthrough, enterprise_required]
description: Discriminator for which gate fired.
documentation_url:
type: string
format: uri
description: Link to the relevant documentation page.
example: https://docs.zernio.com/billing/payment-method-required
dashboard_url:
type: string
format: uri
description: |
Deep-link to send the end-user to. For
`free_tier_exceeded` and `twitter_passthrough` this is
the Zernio billing tab. For `enterprise_required` this
is the Zernio enterprise contact page.
example: https://zernio.com/dashboard?tab=billing
details:
type: object
description: Structured context for SDK clients that want to render their own UX. Keys vary by `reason`.
properties:
free_tier_account_limit:
type: integer
description: How many accounts the free tier allows. Only set when reason=free_tier_exceeded.
example: 2
current_account_count:
type: integer
description: How many accounts the team currently has connected. Set when reason=free_tier_exceeded or reason=enterprise_required.
example: 5
has_payment_method:
type: boolean
description: Whether the team currently has a card on file in Stripe. Set when reason=free_tier_exceeded or reason=twitter_passthrough.
effective_account_limit:
type: integer
description: |
The negotiated connected-account cap from the
team's enterprise contract. Self-service teams
have no cap and never receive this reason. Only
set when reason=enterprise_required.
example: 2000
examples:
freeTierExceeded:
summary: Free tier exceeded (no card on file)
value:
error: 'Add a payment method to connect more than 2 accounts.'
code: PAYMENT_REQUIRED
reason: free_tier_exceeded
documentation_url: https://docs.zernio.com/billing/payment-method-required
dashboard_url: https://zernio.com/dashboard?tab=billing
details:
free_tier_account_limit: 2
current_account_count: 3
has_payment_method: false
twitterPassthrough:
summary: Connecting first X account without a card
value:
error: 'X (Twitter) requires a payment method due to API pass-through costs. Add a payment method to connect an X account.'
code: PAYMENT_REQUIRED
reason: twitter_passthrough
documentation_url: https://docs.zernio.com/billing/payment-method-required
dashboard_url: https://zernio.com/dashboard?tab=billing
details:
has_payment_method: false
enterpriseRequired:
summary: Team reached the negotiated cap on its enterprise contract
value:
error: 'You have 2000 connected accounts, which reaches the 2,000-account limit on your contract. Contact us to raise it.'
code: PAYMENT_REQUIRED
reason: enterprise_required
documentation_url: https://docs.zernio.com/billing/payment-method-required
dashboard_url: https://zernio.com/enterprise
details:
effective_account_limit: 2000
current_account_count: 2000
schemas:
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.
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).
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
EXACTLY ONE of the two shapes:
- Single-creative: `headline`, `body`, and one of `imageUrl` / `video`,
OR `existingPostId` / `objectStoryId` to reuse an organic post.
- Multi-creative: a non-empty `creatives[]` array. Top-level
creative fields must NOT be set on this shape.
Existing post references work on messaging and CTWA only (not call ads).
They cannot be combined with each other or with headline, body, imageUrl,
video, or welcomeMessage. No media is uploaded and the organic post is
retained. Fresh creatives still require headline, body, and image or video.
The route enforces this at the Zod boundary; OpenAPI's
`required` cannot express the OR cleanly.
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.
On the multi-creative shape, each ad's Meta name gets a
" #N" suffix (1-indexed) so Ads Manager shows them as a
numbered batch.
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
`creatives[]`.
body:
type: string
minLength: 1
description: |
Primary text shown above the image / video. Single-creative
shape only. Mutually exclusive with `creatives[]`.
imageUrl:
type: string
format: uri
description: |
Image asset for single-creative shape. Mutually exclusive
with `video` and with `creatives[]`. Required on the
single-creative shape if neither `video` nor an existing post reference is supplied.
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
Meta's own preferred video thumbnail. When Meta produces no
candidate the request fails with a 502 platform_error
(reason: video_thumbnail_unavailable). Retry, or supply
this field to control the poster frame exactly.
description: |
Video creative for single-creative shape. Mutually
exclusive with `imageUrl` and with `creatives[]`. Required
on the single-creative shape if neither `imageUrl` nor an existing post reference is supplied.
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`,
"Mensaje de bienvenida" / "Mensaje predefinido" in Ads Manager).
Single-creative shape only; for `creatives[]` set it per entry.
creatives:
type: array
minItems: 1
description: |
Multi-creative shape: N CTWA ads under one campaign + one
ad set, sharing budget and targeting. Mutually exclusive
with the top-level single-creative fields (`headline` /
`body` / `imageUrl` / `video`): setting both is a 400,
unlike `POST /v1/ads/create` where the top-level fields
are silently ignored in multi-creative mode. Each entry
supplies headline, body, and image/video, or an existingPostId or
objectStoryId reference. Fresh and existing creatives can be mixed.
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
`video`. Required if neither `video` nor an existing post reference is supplied.
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 Meta's own preferred video thumbnail. When Meta
produces no candidate the request fails with a 502
platform_error (reason: video_thumbnail_unavailable).
description: |
Video creative. Mutually exclusive with this entry's
`imageUrl`. Required if neither `imageUrl` nor an existing post reference is supplied.
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
building a campaign, so the ad set keeps its learning phase. It then
owns budget, targeting and schedule, so `budgetAmount`, `budgetType`,
`endDate`, `objective`, `countries`, `interests`, `audienceId` and
`campaignStatus` are rejected with a 400 alongside it. Its
`destination_type` must match the ad's destination.
budgetAmount:
type: number
exclusiveMinimum: 0
description: |
Budget amount in the ad account's currency major units
(e.g. dollars for USD, not cents). Must be > 0.
Required unless `adSetId` is set, where the ad set owns it.
budgetType:
type: string
enum: [daily, lifetime]
description: Required unless `adSetId` is set.
currency:
type: string
minLength: 3
maxLength: 3
description: |
ISO 4217 currency code matching the ad account's currency
(e.g. `USD`). Optional: 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).
endDate:
type: string
format: date-time
description: |
ISO 8601 datetime. Required when `budgetType` is `lifetime`.
countries:
type: array
items: { type: string, minLength: 2, maxLength: 2 }
description: |
ISO 3166-1 alpha-2 country codes. Defaults to `["US"]` only
when no other geo (`cities`, `regions`, `zips`, `metros`,
`customLocations`) is supplied.
cities:
type: array
description: |
City-level geo targeting for local CTWA campaigns. Each entry maps to Meta's
TargetingGeoLocationCity. `key` is Meta's city ID. `radius`
and `distance_unit` are coupled: set both or neither.
Meta enforces a minimum city radius (~17 km / 10 mi);
smaller values resolve to a 0-size audience and the ad
fails at launch. For a tighter catchment use customLocations
(lat/lng).
items:
type: object
required: [key]
properties:
key: { type: string, minLength: 1 }
radius: { type: number, exclusiveMinimum: 0 }
distance_unit: { type: string, enum: [mile, kilometer] }
regions:
type: array
description: |
Region / state-level geo targeting. `key` is Meta's region
ID (lookupable via GET /v1/ads/targeting/search?type=region).
items:
type: object
required: [key]
properties:
key: { type: string, minLength: 1 }
zips:
type: array
description: |
ZIP / postal-code geo targeting. `key` is the platform's
postal id resolved via /v1/ads/targeting/search.
items:
type: object
required: [key]
properties:
key: { type: string, minLength: 1 }
name: { type: string }
metros:
type: array
description: |
DMA / metro-area geo targeting. `key` is Meta's metro id
(e.g. `DMA:807`).
items:
type: object
required: [key]
properties:
key: { type: string, minLength: 1 }
name: { type: string }
countryGroups:
type: array
description: |
Meta only. Continents and trade blocs (`geo_locations.country_groups`),
for targeting a whole region without listing its countries. Combines
with `countries` rather than replacing it, and is also accepted under
`excludedLocations`. Discoverable via
`GET /v1/ads/targeting/search?dimension=geo&geoType=country_group`.
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`).
Use for targeting a radius around a specific lat/long when
no Meta city/region key fits. `distanceUnit` is required.
items:
type: object
required: [latitude, longitude, radius, distanceUnit]
properties:
latitude: { type: number, minimum: -90, maximum: 90 }
longitude: { type: number, minimum: -180, maximum: 180 }
radius: { type: number, exclusiveMinimum: 0 }
distanceUnit: { type: string, enum: [mile, kilometer] }
name: { type: string }
address: { type: string }
ageMin: { type: integer, minimum: 13, maximum: 65 }
ageMax: { type: integer, minimum: 13, maximum: 65 }
interests:
type: array
items:
type: object
required: [id]
properties:
id: { type: string }
name: { type: string }
audienceId:
type: string
description: Custom audience ID to target.
placements:
type: object
description: |
Manual ad placements on the shared ad set. Omit
for automatic placements. When set, restricts delivery to the chosen surfaces,
mapped onto the ad set's `targeting.{publisher_platforms, facebook_positions, instagram_positions,
messenger_positions, audience_network_positions, threads_positions,
whatsapp_positions, device_platforms}`. Enum membership is validated here; Meta
additionally enforces co-selection rules and restricts which
placements are eligible for click-to-WhatsApp ads, returning an actionable
error which we surface.
properties:
publisherPlatforms:
type: array
items: { type: string, enum: [facebook, instagram, threads, messenger, audience_network, whatsapp] }
description: "Top-level platforms to deliver on. A position field below is only honoured when its parent platform is included here."
facebookPositions:
type: array
items: { type: string, enum: [feed, right_hand_column, marketplace, video_feeds, story, search, instream_video, facebook_reels, facebook_reels_overlay, profile_feed, notification] }
instagramPositions:
type: array
items: { type: string, enum: [stream, story, explore, explore_home, reels, profile_feed, ig_search, profile_reels] }
messengerPositions:
type: array
items: { type: string, enum: [messenger_home, sponsored_messages, story] }
audienceNetworkPositions:
type: array
items: { type: string, enum: [classic, rewarded_video] }
threadsPositions:
type: array
items: { type: string, enum: [threads_stream] }
whatsappPositions:
type: array
items: { type: string, enum: [status] }
devicePlatforms:
type: array
items: { type: string, enum: [mobile, desktop] }
description: "Restrict by device. Omit to deliver on both mobile and desktop."
advantageAudience:
type: integer
enum: [0, 1]
description: |
Meta's Advantage+ audience expansion. `0` (default) keeps
targeting strict; `1` lets Meta expand beyond the supplied
targeting when its delivery system finds better matches.
Always sent on CREATE (Meta requires it).
objective:
type: string
enum: [OUTCOME_ENGAGEMENT, OUTCOME_SALES, OUTCOME_LEADS]
description: |
Defaults to `OUTCOME_ENGAGEMENT`. `OUTCOME_SALES` and `OUTCOME_LEADS` require
additional account configuration (Dataset linked to the WABA
for sales) and may be rejected by Meta if missing.
status:
type: string
enum: [ACTIVE, PAUSED]
description: |
Ad-level status. Defaults to `ACTIVE`. `PAUSED` skips activating the
newly created ad(s) after Meta accepts them.
campaignStatus:
type: string
enum: [ACTIVE, PAUSED]
description: |
Campaign-level status, same semantics as `POST /v1/ads/create`. Defaults
to `ACTIVE`. `PAUSED` holds activation at the campaign so it never
spends before the advertiser reviews it, while the ad set and ad still
switch on (one resume call brings the whole hierarchy live). Only
meaningful when a new campaign is being created; rejected with a 400
alongside `adSetId` (the attach shape reuses an existing campaign).
bidStrategy:
type: string
enum:
- LOWEST_COST_WITHOUT_CAP
- LOWEST_COST_WITH_BID_CAP
- COST_CAP
- LOWEST_COST_WITH_MIN_ROAS
description: |
Meta bid strategy applied to the shared ad set. Defaults to
`LOWEST_COST_WITHOUT_CAP` (auto-bid) when omitted.
`LOWEST_COST_WITH_BID_CAP` and `COST_CAP` require
`bidAmount`. `LOWEST_COST_WITH_MIN_ROAS` requires
`roasAverageFloor`. CTWA's `optimization_goal` is fixed to
`CONVERSATIONS`, but the bid strategy is independent.
bidAmount:
type: number
exclusiveMinimum: 0
description: |
Whole currency units (e.g. `5` = $5.00 on a USD account).
Required when `bidStrategy` is `LOWEST_COST_WITH_BID_CAP`
or `COST_CAP`; rejected otherwise.
roasAverageFloor:
type: number
exclusiveMinimum: 0
description: |
Decimal ROAS multiplier (e.g. `2.0` = 2.0× ROAS floor).
Required when `bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`;
rejected otherwise. Meta enforces its own upper bound
server-side.
dsaBeneficiary:
type: string
maxLength: 100
description: |
Legal entity that benefits from the ad. Required when targeting EU users
(EU DSA, Article 26). Optional if the ad account has a default beneficiary:
set it once via `PATCH /v1/ads/accounts` or in Meta Ads Manager, and Meta
fills it in whenever the field is omitted.
dsaPayor:
type: string
maxLength: 100
description: |
Legal entity that pays for the ad. Can differ from `dsaBeneficiary`
(for example, an agency paying for a client's ads). Same rules as
`dsaBeneficiary`: required for EU targeting unless the ad account has
a default payor.
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.
Values are numeric IDs from the advertiser's Meta verification/authorization setup.
Keys depend on the declared category: BRAZIL_REGULATION and THAILAND_UNIVERSAL use
universal_beneficiary / universal_payer; SINGAPORE_UNIVERSAL uses
singapore_universal_beneficiary / singapore_universal_payer; TAIWAN_UNIVERSAL uses
taiwan_universal_beneficiary / taiwan_universal_payer; TAIWAN_FINSERV uses
taiwan_finserv_beneficiary / taiwan_finserv_payer; AUSTRALIA_FINSERV uses
australia_finserv_beneficiary / australia_finserv_payer; INDIA_FINSERV uses
india_finserv_beneficiary / india_finserv_payer.
Both beneficiary and payer must be included. If omitted and the advertiser has
set defaults in Meta Ads Manager advertising settings, Meta auto-fills them.
WorkflowNode:
type: object
required: [id, type]
description: A node in a workflow graph. `config` shape depends on `type`.
properties:
id: { type: string, description: Stable node id referenced by edges }
type:
type: string
description: >
Node kind. The 16 supported types break into four groups:
messaging (send_message),
control flow (trigger, condition, delay, wait_for_reply, a_b_split, end),
data ops (set_variable, set_field, add_tag, remove_tag, enroll_sequence),
integrations (webhook, ai, handoff, start_call).
enum:
- trigger
- send_message
- wait_for_reply
- condition
- set_variable
- delay
- webhook
- ai
- handoff
- start_call
- a_b_split
- set_field
- enroll_sequence
- add_tag
- remove_tag
- end
config:
type: object
additionalProperties: true
description: >
Type-specific settings. All string fields support `{{variable}}` interpolation against the
run's variable bag (resolved at execution time).
**trigger**: `{ triggerType: inbound_message|api_call|whatsapp_event, keywords:[string],
matchType: any|contains|exact|regex, onlyFirstMessage:boolean, eventType:
message_sent|message_delivered|message_read|message_failed|reaction }`.
Default `triggerType` is `inbound_message` for legacy nodes. `eventType` is only honored
when `triggerType` is `whatsapp_event` (WhatsApp-only).
**send_message**: `{ messageType: text|template|media|interactive, text,
template:{name,language,variableMapping}, media:{mediaType:image|video|audio|document,
url,caption}, interactive }`. `template` and `interactive` are WhatsApp-only.
`interactive.type` is inferred from the payload shape when omitted; payloads with
neither `type` nor an inferable shape are rejected.
**wait_for_reply**: `{ timeoutMinutes:int (max 43200), saveAs:string }`. Resume via
the `'reply'` edge on inbound, or `'timeout'` edge after `timeoutMinutes` of silence.
**condition**: `{ rules:[{ id, variable, operator:
equals|not_equals|contains|not_contains|starts_with|ends_with|exists|not_exists|matches,
value }] }`. First matching rule takes its `id` as the sourceHandle; otherwise
`'default'`.
**set_variable**: `{ assignments:[{ name, value }] }`. Run-scoped (lives only for this
execution; use `set_field` for persistent values).
**delay**: `{ delayMinutes:int (max 43200) }`. Suspends the run, resumes via timer.
**webhook**: `{ url, method: GET|POST|PUT|PATCH|DELETE, headers, bodyTemplate, saveAs }`.
SSRF-guarded (private/loopback/metadata IPs rejected). Response saved as
`{ status, ok, body }` to `vars[saveAs]`. Edge: `'success'` on 2xx, `'error'` otherwise.
**ai**: `{ provider: anthropic|openai|google|mistral|groq|openrouter, model, preset:
smart|tools|cheap, systemPrompt, userPromptTemplate, saveAs, temperature, maxTokens,
outputType: text|json, tools:[{ name, description, parameters }] }`. Set `provider` +
`model` for BYOK (uses your stored API key); omit `provider` for the legacy Telnyx
path. Edges: `'success'`, `'tool:<name>'` (model picked a tool), `'error'`.
**handoff**: `{ note, assignTo }`. Terminates the run as `exited`, flags the
conversation for a human operator.
**start_call**: `{ to, forwardTo, requirePermissionFirst, recordingEnabled, saveAs }`.
WhatsApp-only. `forwardTo` can be `tel:+E164`, `sip:user@host`, or `wss://…` (AI voice
agent). Edges: `'success'`, `'permission_required'`, `'failed'`.
**a_b_split**: `{ percentage: number 0-100 (default 50) }`. Random branch picker.
Edges: `'a'` (with probability `percentage/100`), `'b'`.
**set_field**: `{ field, value }`. Persistent custom field on the Contact (vs
`set_variable` which is run-scoped). Field name is sanitized to `[A-Za-z0-9_]`.
No-op on `api_call` runs (no contact).
**enroll_sequence**: `{ sequenceId, saveAs }`. Enrolls the run's contact into a
Sequence. Edges: `'success'`, `'error'`.
**add_tag** / **remove_tag**: `{ tag }`. Push or pull a tag on the Contact. No-op on
`api_call` runs.
**end**: no config. Terminates the run as `completed`.
position:
type: object
description: Canvas coordinates (ignored by the executor; used by the visual builder).
properties:
x: { type: number }
y: { type: number }
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:
- **condition**: a rule's `id`, or `'default'` (no rule matched)
- **wait_for_reply**: `'reply'` (contact replied) | `'timeout'` (no reply in window)
- **webhook**: `'success'` (2xx) | `'error'` (non-2xx / fetch failed)
- **ai**: `'success'` (text/JSON response) | `'tool:<toolName>'` (model invoked
that tool) | `'error'` (upstream failure / non-JSON in JSON mode)
- **start_call**: `'success'` | `'permission_required'` | `'failed'`
- **a_b_split**: `'a'` | `'b'`
- **enroll_sequence**: `'success'` | `'error'`
WorkflowExecutionEvent:
type: object
description: >
One entry in a workflow execution's timeline. Emitted by the executor on every node
visit and lifecycle transition, surfaced by `GET /v1/workflows/{workflowId}/executions/
{executionId}/events` for run inspection in the Runs UI.
properties:
action:
type: string
enum:
- execution_started
- execution_completed
- execution_exited
- execution_paused
- execution_resumed
- node_started
- node_completed
- node_failed
- node_skipped
status: { type: [string, "null"], enum: [success, failed, pending] }
nodeId: { type: [string, "null"], description: Present on `node_*` events }
nodeType: { type: [string, "null"], description: Present on `node_*` events }
sourceHandle:
type: [string, "null"]
description: The edge handle the executor followed out of this node (see `WorkflowEdge.sourceHandle`)
durationMs: { type: [integer, "null"], description: Node run time; present on `node_completed` and `node_failed` }
errorMessage: { type: [string, "null"], description: Failure detail; present on `node_failed` and `execution_exited` }
meta:
type: [object, "null"]
additionalProperties: true
description: >
Per-node-type payload. Shape varies; see WorkflowNode `type`. Examples:
`send_message` → `{ messageType, text, recipient }`,
`webhook` → `{ url, method, statusCode, responseTimeMs, responsePreview }`,
`ai` → `{ model, provider, inputTokens, outputTokens, responsePreview }`,
`condition` → `{ matchedHandle, rulesEvaluated }`,
`a_b_split` → `{ percentage, chosen }`.
at: { type: string, format: date-time, description: Event timestamp (UTC) }
BulkUploadResult:
type: object
description: |
Result of a CSV bulk upload. The same shape is returned for `200` (all rows
succeeded or all failed) and `207` (mixed). Per-row outcomes live in `results`;
the row's success is `ok`, and failures carry machine-readable codes in `errors`.
properties:
total: { type: integer, description: "Number of data rows processed from the CSV" }
valid: { type: integer, description: "Count of rows that succeeded (results[].ok === true)" }
invalid: { type: integer, description: "Count of rows that failed (total - valid)" }
results:
type: array
description: One entry per CSV data row, in row order.
items:
type: object
properties:
rowIndex: { type: integer, description: "1-based index of the CSV data row (header excluded)" }
ok: { type: boolean, description: Whether the row was created successfully }
createdPostId:
type: string
description: ID of the created post. Present only when `ok` is true and not a dry run.
errors:
type: array
description: |
Machine-readable failure codes for this row. Present only when `ok` is false.
Examples: `unknown_profile:<id>`, `no_account_for_platform:<platform>`,
`schedule_time_missing`, `rate_limited:<platform>:@<username>:<remaining>`.
items: { type: string }
warnings:
type: array
description: "Top-level advisory warnings, e.g. `rows_exceed_advisory_limit:500` 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.
Lets callers map `rate_limited:*` row errors back to structured metadata without
parsing the error strings.
items:
type: object
properties:
accountId: { type: string }
platform: { type: string }
username: { type: string }
rateLimitedUntil: { type: string, format: date-time }
RedditPost:
type: object
description: A normalized Reddit post returned by the feed and search endpoints
properties:
id: { type: string, description: Reddit post ID (without type prefix) }
fullname: { type: string, description: "Reddit fullname (e.g. t3_abc123)" }
title: { type: string }
author: { type: string }
subreddit: { type: string }
url: { type: string, description: "Post URL (may be a gallery URL, external link, or self-post URL)" }
permalink: { type: string, description: Full permalink to the Reddit post }
selftext: { type: string, description: Self-post body text (empty string for link posts) }
createdUtc: { type: number, description: Unix timestamp of post creation }
score: { type: integer }
numComments: { type: integer }
over18: { type: boolean, description: Whether the post is marked NSFW }
stickied: { type: boolean }
flairText: { type: [string, "null"], description: Link flair text if set }
isGallery: { type: boolean, description: Whether the post is a gallery with multiple images }
galleryImages:
type: array
description: Individual image URLs for gallery posts (only present when isGallery is true)
items: { type: string, format: uri }
ExternalPostSummary:
type: object
description: |
A post synced from a platform (published directly on the platform, not
through Zernio). Returned by GET /v1/posts?source=external and
POST /v1/posts/sync-external. Analytics are exposed separately via
GET /v1/analytics?source=external.
properties:
platform:
type: string
description: Platform the post belongs to (e.g. instagram, youtube, tiktok)
platformPostId:
type: string
description: The platform's own post/media/video id
platformPostUrl:
type: string
description: Canonical URL (permalink) of the post on the platform
content:
type: string
description: Post caption / text
publishedAt:
type: string
format: date-time
description: When the post was published on the platform
mediaType:
type: string
description: Media type (e.g. image, video, carousel)
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
available immediately after an on-demand sync (they come from the
platform listing). `reach`, `impressions`, `views` depend on the
platform's insights, which carry their own delay (e.g. ~24h on
Instagram) and read 0 until the platform makes them available.
properties:
likes: { type: integer }
comments: { type: integer }
shares: { type: integer }
saves: { type: integer }
sends: { type: integer }
clicks: { type: integer }
views: { type: integer }
reach: { type: integer }
impressions: { type: integer }
engagementRate: { type: number, 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`,
`code`, `param`, `platform`, and `platformError` are top-level siblings
for programmatic handling. For upstream platform failures (`type:
platform_error`), `platformError` carries the provider's raw payload
verbatim (for Meta: `error_subcode`, `error_user_title`, `error_user_msg`).
properties:
error:
type: string
description: Human-readable error message.
type:
type: string
enum: [invalid_request_error, authentication_error, permission_error, not_found, rate_limit_error, platform_error, api_error]
description: Error class for programmatic handling.
code:
type: string
description: Stable machine-readable error code.
param:
type: string
description: The request field that caused the error, when applicable.
platform:
type: string
description: "Upstream platform (e.g. meta, google, tiktok), present when type is platform_error."
platformError:
type: object
additionalProperties: true
description: |
Raw error payload from the upstream platform, passed through verbatim so
integrators can read provider-specific codes. For Meta this includes
error_subcode, error_user_title, and error_user_msg.
details:
type: object
additionalProperties: true
description: |
Additional structured context (e.g. field-level validation errors), for example
`privateReplyConsumed` on the private-reply endpoint's 400 when the comment's
single reply is already spent.
On a Google Ads 429 it carries `quotaExhausted: true`, which marks the failure as
Google's own ads quota rather than a Zernio rate limit, so you can keep calling
other platforms instead of backing off everywhere. When Google names the scope it
also carries `quotaScope`: `DEVELOPER` means the shared developer-token budget
(every Google account is affected and there is nothing to change on your side),
`ACCOUNT` means your own ad account. A Meta 429 carries neither field.
A Zernio Google Ads budget 429 carries `budgetScope` instead, and never
`quotaExhausted`: these are Zernio's own limits, applied before the call
reaches Google. `user` is your own burst or daily allowance, so the work is
yours to reschedule; `platform` is the fleet-wide daily budget shared with
every other customer, so only waiting for the reset clears it. The two
scopes are separate axes from `quotaScope`, not the same pool named twice.
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.'
budgetScope:
type: string
enum: [user, platform]
description: "Zernio Google Ads operations-budget 429 only (never set alongside `quotaExhausted`). `user` is your own burst/daily allowance; `platform` is the fleet-wide daily budget shared across customers."
CommentAutomationAudience:
type: object
description: |
Who a comment automation answers. Instagram only - Meta exposes the follow
relationship on no other platform, and only for people who have MESSAGED the
account (a comment grants no consent). `whenUnknown` is therefore the important
setting: it decides what happens for a first-time commenter.
properties:
followerStatus:
type: string
enum: [any, follower, non_follower]
default: any
minFollowerCount:
type: integer
minimum: 0
description: 'Skip commenters with fewer followers than this. Omit for no size rule.'
whenUnknown:
type: string
enum: [send, skip, verify]
default: send
description: |
What to do when Instagram will not reveal the follow relationship.
* `send` (default) - deliver the DM anyway (fails open).
* `skip` - stay silent.
* `verify` - send `followGate.message` with a confirm button. Tapping it is a
message, which grants consent, so the re-check on the tap resolves and the
real DM (or `followGate.notFollowingMessage`) follows automatically.
CommentAutomationFollowGate:
type: object
description: 'Copy for the follow gate. Sensible defaults are used for any field left empty.'
properties:
message:
type: string
maxLength: 640
description: 'Confirmation DM sent when whenUnknown=verify.'
buttonLabel:
type: string
maxLength: 20
description: 'Confirm button label. Defaults to "I''m following".'
notFollowingMessage:
type: string
maxLength: 1000
description: 'Sent to a commenter we know does not follow (followerStatus=follower). Omit to stay silent on a keyword comment; a confirm tap always gets an answer.'
DmButton:
type: object
description: |
A single inline button rendered inside an auto-DM via Meta's button_template.
Up to 3 buttons per automation. `url` and `postback` work on Instagram and
Facebook; `phone` is Facebook-only. When buttons are set, `dmMessage` becomes
the button_template text and must be 640 characters or less.
required: [type, title]
properties:
type: { type: string, enum: [url, postback, phone] }
title: { type: string, maxLength: 20, description: Button label (20 chars max) }
url: { type: string, format: uri, description: Target URL (required when type is url) }
payload: { type: string, description: Postback payload delivered via the messaging_postbacks webhook (required when type is postback) }
phone: { type: string, description: "Phone number, e.g. +14155551234 (required when type is phone; Facebook only)" }
CommentAutomationTemplate:
type: object
description: |
A Meta generic template (product card) sent as the automation's first DM.
It REPLACES the plain `dmMessage` bubble: a Meta message carries one body
shape, and a comment gets exactly one private reply, so the card and the
text cannot both be delivered. Put your selling copy in `subtitle`.
Mutually exclusive with `buttons` (sending both is a 400). Works on both
the `comment` and `story_reply` triggers.
Up to 10 elements, rendered as a horizontally swipeable carousel.
Rendering confirmed on the Instagram and Messenger mobile apps.
required: [type, elements]
properties:
type: { type: string, enum: [generic] }
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.
Transitions `pending → active` when the inbound webhook receives a reply
from the matching phone (the reply itself proves ownership).
required: [id, phoneE164, status, expiresAt]
properties:
id:
type: string
description: Session id. Use this to revoke via DELETE.
phoneE164:
type: string
description: Digits-only E.164 form (no +, spaces, or dashes).
example: "34688246216"
status:
type: string
enum: [pending, active]
description: |
`pending` until the phone replies to the activation template, then
`active`. Expired sessions are pruned by TTL and never appear in
list responses.
expiresAt:
type: string
format: date-time
description: |
UTC timestamp at which the session becomes invalid. Pending sessions
get a 24h window; activated sessions get 7 days.
activatedAt:
type: [string, "null"]
format: date-time
description: When the session transitioned `pending → active`, or null.
createdAt:
type: [string, "null"]
format: date-time
FoodMenuLabel:
type: object
required: [displayName]
properties:
displayName: { type: string, description: Display name of the item/section/menu }
description: { type: string, description: Optional description }
languageCode: { type: string, description: "BCP-47 language code (e.g. en, es)" }
Money:
type: object
required: [currencyCode, units]
properties:
currencyCode: { type: string, description: "ISO 4217 currency code (e.g. USD, EUR)" }
units: { type: string, description: Whole units of the amount }
nanos: { type: integer, description: Nano units (10^-9) of the amount }
FoodMenuItemAttributes:
type: object
properties:
price: { $ref: '#/components/schemas/Money' }
spiciness: { type: string, enum: [SPICINESS_UNSPECIFIED, MILD, MEDIUM, HOT], description: "Spiciness level (e.g. MILD, MEDIUM, HOT)" }
allergen:
type: array
items: { type: string }
description: "Allergens (e.g. DAIRY, GLUTEN, SHELLFISH)"
dietaryRestriction:
type: array
items: { type: string }
description: "Dietary labels (e.g. VEGETARIAN, VEGAN, GLUTEN_FREE)"
servesNumPeople: { type: integer, minimum: 1, description: Number of people the item serves }
preparationMethods:
type: array
items: { type: string }
description: "Preparation methods (e.g. GRILLED, FRIED)"
mediaKeys:
type: array
items: { type: string }
description: Media references for item photos
FoodMenuItem:
type: object
required: [labels]
properties:
labels:
type: array
minItems: 1
items: { $ref: '#/components/schemas/FoodMenuLabel' }
attributes: { $ref: '#/components/schemas/FoodMenuItemAttributes' }
options:
type: array
items:
type: object
required: [labels, attributes]
properties:
labels:
type: array
minItems: 1
items: { $ref: '#/components/schemas/FoodMenuLabel' }
attributes: { $ref: '#/components/schemas/FoodMenuItemAttributes' }
description: Item variants/options (e.g. sizes, preparations)
FoodMenuSection:
type: object
required: [labels]
properties:
labels:
type: array
minItems: 1
items: { $ref: '#/components/schemas/FoodMenuLabel' }
items:
type: array
items: { $ref: '#/components/schemas/FoodMenuItem' }
FoodMenu:
type: object
required: [labels]
properties:
labels:
type: array
minItems: 1
items: { $ref: '#/components/schemas/FoodMenuLabel' }
sections:
type: array
items: { $ref: '#/components/schemas/FoodMenuSection' }
cuisines:
type: array
items: { type: string }
description: "Cuisine types (e.g. AMERICAN, ITALIAN, JAPANESE)"
sourceUrl:
type: string
format: uri
description: URL of the original menu source
YouTubeDailyViewsResponse:
type: object
properties:
success:
type: boolean
example: true
videoId:
type: string
description: The YouTube video ID
durationSeconds:
type: [integer, "null"]
description: Video length in seconds (from YouTube contentDetails.duration)
dateRange:
type: object
properties:
startDate:
type: string
format: date
endDate:
type: string
format: date
provisionalSince:
type: string
format: date
description: 'Present only when the range reaches into YouTube''s ~3-day processing window: the first date whose numbers are provisional and may still be revised by YouTube.'
totalViews:
type: integer
description: Sum of views across all days in the range
dailyViews:
type: array
items:
type: object
properties:
date:
type: string
format: date
views:
type: integer
estimatedMinutesWatched:
type: number
averageViewDuration:
type: number
description: Average view duration in seconds
averageViewPercentage:
type: number
description: Average percentage of the video watched per view. Can exceed 100 on Shorts (looping rewatches), so do not clamp it client-side.
subscribersGained:
type: integer
subscribersLost:
type: integer
likes:
type: integer
comments:
type: integer
shares:
type: integer
lastSyncedAt:
type: [string, "null"]
format: date-time
description: When the data was last synced from YouTube
scopeStatus:
type: object
properties:
hasAnalyticsScope:
type: boolean
YouTubeVideoRetentionResponse:
type: object
properties:
success:
type: boolean
example: true
accountId:
type: string
description: The Zernio account ID for the YouTube account
videoId:
type: string
description: The YouTube video ID
title:
type: [string, "null"]
description: Video title
publishedAt:
type: [string, "null"]
format: date-time
description: When the video was published on YouTube
durationSeconds:
type: [integer, "null"]
description: Video length in seconds (from YouTube contentDetails.duration)
dateRange:
type: object
properties:
startDate:
type: string
format: date
endDate:
type: string
format: date
provisionalSince:
type: string
format: date
description: 'Present only when the range reaches into YouTube''s ~3-day processing window: the first date whose numbers are provisional and may still be revised by YouTube.'
retentionCurve:
type: array
description: 'Up to 100 points covering the video timeline, aggregated over the date range. 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
analytics endpoint (/v1/analytics/{facebook|instagram|youtube|linkedin|tiktok}/*).
The name is historical - the shape was first shipped for Instagram and every
new platform endpoint reuses it for response-shape consistency. The platform
field echoes back which platform served the response.
properties:
success:
type: boolean
example: true
accountId:
type: string
description: The Zernio SocialAccount ID
platform:
type: string
description: Platform that served this response.
enum: [facebook, instagram, youtube, linkedin, tiktok]
dateRange:
type: object
properties:
since:
type: string
format: date
until:
type: string
format: date
metricType:
type: string
enum: [time_series, total_value]
breakdown:
type: string
description: Breakdown dimension used (only present when breakdown was requested)
metrics:
type: object
description: |
Object keyed by metric name. For time_series: each metric has "total" (number) and "values" (array of {date, value}).
For total_value: each metric has "total" (number) and optionally "breakdowns" (array of {dimension, value}).
Monetary metrics additionally carry "unit" and "currency". Zernio never rescales money:
"total" and every "values[].value" are the platform's raw numbers in the stated unit.
Monetary metrics also keep "values" on metricType=total_value, because their "total" is the
sum of the daily buckets the platform returned over the range: keep the series so you can
reconcile that sum against the platform's own reporting before invoicing on it.
A metric that could not be served is absent from this object and listed in
"unavailableMetrics" instead, so an unavailable metric is never reported as a zero.
additionalProperties:
type: object
properties:
total:
type: number
description: Sum or aggregate value for the metric
values:
type: array
description: Daily values (for time_series, and always on monetary metrics)
items:
type: object
properties:
date:
type: string
format: date
value:
type: number
breakdowns:
type: array
description: Breakdown values (only for total_value with breakdown)
items:
type: object
properties:
dimension:
type: string
value:
type: number
unit:
type: string
enum: [micro_amount, unspecified]
description: |
Present on monetary metrics only. The scale of "total" and of every "values[].value",
exactly as the platform returned them.
"micro_amount": the platform returned an object shape carrying a micro amount, and the
values are that integer, summed, unconverted. Zernio does not publish a divisor because
Meta does not document one; divide by the scale you have verified against the Page's own
Meta Business Suite export. On Facebook Page insights this is always
content_monetization_earnings.
"unspecified": the platform returned a bare number with no unit metadata. It is passed
through as-is; the platform does not state whether it is major or minor currency units.
On Facebook Page insights this is always monetization_approximate_earnings.
currency:
type: [string, "null"]
description: |
ISO 4217 currency of a monetary metric, or null when the platform omitted it.
Always null on monetization_approximate_earnings, which Meta returns as a bare number
with no currency; always present on content_monetization_earnings.
example: "USD"
unavailableMetrics:
type: array
description: |
Requested metrics that could not be served. Present only when at least one metric is
unavailable, and absent otherwise. Each listed metric is OMITTED from "metrics" rather than
reported as 0, which is how an unavailable metric is distinguished from a genuine zero.
The request itself still succeeds with HTTP 200.
items:
type: object
properties:
metric:
type: string
description: The requested metric name.
reason:
type: string
enum: [not_enrolled, permission_missing, unsupported_metric, no_data, unreadable_value, mixed_currency, upstream_error]
description: |
"not_enrolled": the account is not enrolled in the program behind this metric.
"permission_missing": the connected user lacks access to this metric.
"unsupported_metric": the platform does not accept this metric name on the API version Zernio uses.
"no_data": the platform returned no bucket for this metric over the requested range.
"unreadable_value": the platform returned a value shape Zernio cannot read, so no total is reported.
"mixed_currency": readable values disagree on currency or unit within the range.
"upstream_error": any other platform failure.
"no_data" is the common case in practice. The others are defensive: "not_enrolled" and
"unsupported_metric" in particular have not been observed on live Facebook traffic, since
a non-enrolled Page returns zeros rather than an error and metric names are validated
before any platform call.
message:
type: string
description: 'Platform-provided explanation when available (access tokens redacted), otherwise Zernio copy.'
dataDelay:
type: string
example: "Data may be delayed up to 48 hours"
FacebookPostEarningsResponse:
type: object
description: |
Lifetime monetization earnings for one Facebook post. Same "unit" / "currency" contract and
same unavailable-vs-zero contract as the Page-level response; there is no date range, no
metricType, and no daily "values", because the single lifetime bucket IS the total.
properties:
success:
type: boolean
example: true
accountId:
type: string
example: "64e1a2b3c4d5e6f7a8b9c0d1"
postId:
type: string
description: 'The platform post ID that was queried, echoed back.'
example: "123456789_987654321"
platform:
type: string
example: "facebook"
period:
type: string
enum: [lifetime]
description: |
Always "lifetime": the total is cumulative since publication and must not be summed
across dates or across posts.
metrics:
type: object
description: |
One entry per served metric. A metric reported here with "total": 0 genuinely earned
nothing (or its Page is not enrolled, which Meta reports identically).
additionalProperties:
type: object
properties:
total:
type: number
description: 'Lifetime earnings in "unit", exactly as Meta returned them. Never rescaled.'
unit:
type: string
enum: [micro_amount, unspecified]
description: |
"micro_amount": Meta returned an object shape carrying a micro amount, and "total" is
that integer, unconverted. Zernio does not publish a divisor because Meta does not
document one; divide by the scale you have verified against the Page's own Meta
Business Suite export. This is always content_monetization_earnings.
"unspecified": Meta returned a bare number with no unit metadata, passed through as-is;
Meta does not state whether it is major or minor currency units. This is always
monetization_approximate_earnings.
currency:
type: [string, "null"]
description: |
ISO 4217 currency, or null when Meta omitted it. Always null on
monetization_approximate_earnings; always present on content_monetization_earnings.
example: "USD"
unavailableMetrics:
type: array
description: |
Requested metrics Meta could not serve. Present only when at least one metric is
unavailable, and absent otherwise. Each listed metric is OMITTED from "metrics" rather than
reported as 0. The request itself still succeeds with HTTP 200.
items:
type: object
properties:
metric:
type: string
description: The requested metric name.
reason:
type: string
enum: [not_enrolled, permission_missing, unsupported_metric, no_data, unreadable_value, mixed_currency, upstream_error]
description: |
"not_enrolled": the account is not enrolled in the program behind this metric.
"permission_missing": the connected user lacks access to this metric.
"unsupported_metric": Meta does not accept this metric name on the API version Zernio uses.
"no_data": Meta returned no bucket for this metric.
"unreadable_value": Meta returned a value shape Zernio cannot read, so no total is reported.
"mixed_currency": readable values disagree on currency or unit.
"upstream_error": any other platform failure.
"no_data" is the common case in practice; the others are defensive.
message:
type: string
description: 'Platform-provided explanation when available (access tokens redacted), otherwise Zernio copy.'
dataDelay:
type: string
InstagramDemographicsResponse:
type: object
properties:
success:
type: boolean
example: true
accountId:
type: string
description: The Zernio SocialAccount ID
platform:
type: string
example: "instagram"
metric:
type: string
enum: [follower_demographics, engaged_audience_demographics]
timeframe:
type: string
enum: [this_week, this_month]
description: The timeframe used for demographic data
demographics:
type: object
description: Object keyed by breakdown dimension (age, city, country, gender)
additionalProperties:
type: array
items:
type: object
properties:
dimension:
type: string
description: The dimension value (e.g., "25-34", "US", "M")
value:
type: number
description: Count of accounts in this dimension
note:
type: string
example: "Demographics show top 45 entries per dimension. Requires 100+ followers."
YouTubeDemographicsResponse:
type: object
properties:
success:
type: boolean
example: true
accountId:
type: string
description: The Zernio SocialAccount ID
platform:
type: string
example: "youtube"
videoId:
type: string
description: Present only when demographics are scoped to a single video
title:
type: [string, "null"]
description: 'Video title (video mode only)'
publishedAt:
type: [string, "null"]
format: date-time
description: 'Video publish date (video mode only)'
demographics:
type: object
description: Object keyed by breakdown dimension (age, gender, country)
additionalProperties:
type: array
items:
type: object
properties:
dimension:
type: string
description: The dimension value (e.g., "25-34", "US", "male")
value:
type: number
description: Viewer percentage (age/gender) or view count (country)
dateRange:
type: object
properties:
startDate:
type: string
example: "2026-01-01"
endDate:
type: string
example: "2026-03-31"
provisionalSince:
type: string
format: date
description: 'Present only when the range reaches into YouTube''s ~3-day processing window: the first date whose numbers are provisional and may still be revised by YouTube.'
note:
type: string
example: "Age/gender values are viewer percentages (0-100). Country values are view counts. Data based on signed-in viewers only, with 2-3 day delay."
Webhook:
type: object
description: Individual webhook configuration for receiving real-time notifications
properties:
_id:
type: string
description: Unique webhook identifier
name:
type: string
description: Webhook name (for identification)
maxLength: 50
url:
type: string
format: uri
description: Webhook endpoint URL
secret:
type: string
description: Secret key for HMAC-SHA256 signature verification.
events:
type: array
items:
type: string
enum: [post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled, post.platform.published, post.platform.failed, post.platform.deleted, post.tiktok.url_resolved, post.external.created, post.external.updated, post.external.deleted, account.connected, account.disconnected, account.ads.initial_sync_completed, 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
`post.platform.published` and `post.platform.failed`, for
`post.platform.deleted` (same shape, fired when Zernio's background
sync detects that a platform target published through Zernio was
later deleted on the platform; poll-driven ~hourly, not real-time),
and for `post.tiktok.url_resolved` (same shape, fired when a
published TikTok post's public URL is backfilled). Terminal events
fire once per platform target inside a post as that platform
reaches a terminal state (published or permanent failure), except
that a target which later fails background reconciliation emits
`post.platform.failed` a second time, after its own
`post.platform.published`. The `post` envelope mirrors the shape
of `WebhookPayloadPost` so
consumers can reuse rendering logic; the `platform` block
identifies which specific platform transitioned; the `account`
block identifies the connected account behind that
platform-write.
required: [id, event, post, platform, account, timestamp]
properties:
id:
type: string
description: Stable webhook event ID.
event:
type: string
enum: [post.platform.published, post.platform.failed, post.platform.deleted, post.tiktok.url_resolved]
post:
type: object
required: [id, content, status, scheduledFor, platforms]
properties:
id: { type: string }
content: { type: string }
status:
type: string
description: |
Post-level status AT FIRE TIME. May still be `publishing`
if other platforms haven't terminated; check this field
rather than assuming.
scheduledFor: { type: string, format: date-time }
publishedAt: { type: string, format: date-time }
platforms:
type: array
items:
type: object
required: [platform, status]
properties:
platform: { type: string }
status: { type: string }
accountId: { type: string, description: 'SocialAccount id this platform target published through. On post.platform.* events see also the top-level `account` block.' }
platformPostId: { type: string }
publishedUrl: { type: string }
error: { type: string }
metadata:
type: object
additionalProperties: true
description: The free-form `metadata` object supplied when the post was created, echoed back so you can map events onto your own records. Omitted when the post was created without it.
platform:
type: object
description: The specific platform that 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.
Fired once per ads-enabled account when the initial discovery + 90-day
ad backfill finishes (whether it succeeded fully, partially, or failed).
required: [id, event, account, sync, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [account.ads.initial_sync_completed]
account:
type: object
required: [accountId, profileId, platform, username]
properties:
accountId:
type: string
description: The account's unique identifier (same as used in /v1/accounts/{accountId})
profileId:
type: string
description: The profile's unique identifier this account belongs to
platform:
type: string
username:
type: string
displayName:
type: string
platformUserId:
type: string
description: The platform-side account/ad-account ID (e.g. Meta ad account ID).
profilePicture:
type: string
format: uri
description: URL of the account's profile picture, when available.
platformAdAccountId:
type: string
description: |
When the consumer scoped the connect call to a single ad account, this echoes
that ID back so the webhook can be correlated to the originating connect
request without consulting the consumer's DB. Meta uses the `act_*` shape.
example: act_1330190928038136
platformAdAccountIds:
type: array
description: |
Every ad-account ID that the connected token could see at discovery time.
Useful for "we synced ads from these accounts" UX without a follow-up API call.
Empty array when the token had no ad-account visibility.
items:
type: string
example: ["act_1330190928038136", "act_98765432101234"]
sync:
type: object
description: Summary of the initial ads sync backfill results.
required: [status, totalAds, synced, failed]
properties:
status:
type: string
enum: [success, failure]
description: Overall outcome of the initial sync.
totalAds:
type: integer
description: Total number of ads discovered for backfill.
synced:
type: integer
description: Number of ads successfully synced.
failed:
type: integer
description: Number of ads that failed to sync.
error:
type: string
description: |
Free-form error message from the platform (typically Meta's Marketing API).
Truncated to ~2KB. Present when `status` is `failure` (and sometimes on `success`
when discovery saw zero ad accounts). For UX branching prefer `errorCategory`;
this field is for human display and debugging.
errorCode:
type: string
description: Platform-native error code if parsed (e.g. Meta `190`, `10`, `200`).
errorSubcode:
type: string
description: Platform-native error subcode if parsed.
errorCategory:
type: string
enum: [token_invalid, permission_denied, no_ad_accounts, rate_limited, discovery_failed, unknown]
description: |
Stable category for UX branching. New values may be added; existing ones are
stable. Mapping:
- `token_invalid`: access token is expired or revoked. Reconnect.
- `permission_denied`: token lacks required scope, or the user has no role
on the Business Manager that owns the ad account. Reconnect with full
permissions, or have an admin grant access.
- `no_ad_accounts`: token is valid but sees zero ad accounts. The user
needs to connect a Business Manager that owns ad accounts.
- `rate_limited`: platform throttled us. Sync will retry automatically.
- `discovery_failed`: any other platform-side failure. Inspect `error`.
- `unknown`: classifier could not categorize the failure.
timestamp:
type: string
format: date-time
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
time its analytics sync cycle completes successfully. Poll-driven (roughly
hourly per account), not real-time, and never fired for a skipped or failed
cycle.
A TRIGGER, not a transport: it deliberately carries no metrics and no cursor.
When it arrives, call `GET /v1/analytics/delta` with YOUR OWN last `nextCursor`
to read what changed, across every account, in one paginated stream.
The absent cursor is deliberate. The feed's ordering position is assigned inside
the analytics store when the row is materialized, which normally has not
happened yet at the moment this event fires, so a cursor minted here could sit
ahead of the very rows the event announces and make you skip them. Your own
`nextCursor` is always in the feed's own ordering and can never do that.
Because of that same lag, a delta read issued the instant this event lands can
legitimately come back empty. That is not "nothing changed": poll again with the
same cursor you last used rather than treating the account as done.
Subscribe to this event on a DEDICATED webhook endpoint. It is high volume
(roughly one delivery per connected account per hour) and a subscription's
consecutive-failure count is shared across all of its events, so an outage while
this event is flowing can suppress the low-volume publishing events that share
the same subscription.
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
cycle produces carry a `syncedAt` stamped when the cycle STARTED, which
is measured at around one second earlier at the median and up to a
couple of minutes earlier in the tail. Correlate on `account.accountId`.
postsUpdated:
type: integer
description: |
Post records created or modified by this cycle. Not the number of delta
feed rows the cycle produced, which the syncer does not report, so a
cycle with a non-zero `postsUpdated` can still yield an empty delta page.
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. Populated on the Instagram and Facebook realtime webhooks (Meta re-delivers the account''s own replies as comments events) and on TikTok, where it is inferred: comments created through this API are always flagged, and once the account''s own author identifier is known (from one of those or from a comments listing) every author is compared against it. Absent means not evaluated, never "not the account".'
instagramProfile:
type: object
description: |
Instagram only, best-effort. Present ONLY for commenters who have
messaged the account before: Meta gates the follow relationship behind
messaging consent, and commenting does not grant it. Absent otherwise -
treat a missing object as "unknown", never as "not a follower". To check
on demand, call GET /v1/accounts/{accountId}/follow-status/{userId}.
properties:
isFollower:
type: [boolean, "null"]
description: The commenter follows this account.
isFollowing:
type: [boolean, "null"]
description: This account follows the commenter.
followerCount:
type: [integer, "null"]
isVerified:
type: [boolean, "null"]
createdAt:
type: string
format: date-time
isReply:
type: boolean
description: Whether this is a reply to another comment
parentCommentId:
type: [string, "null"]
description: Parent comment ID if this is a reply
ad:
type: object
description: |
Ad context. Present only when the comment was made on paid content.
Instagram: populated from the webhook payload's value.media.ad_id and value.media.ad_title.
Facebook: populated via a Graph API lookup of the parent post's promotion_status.
Absent for comments on organic posts that are not currently promoted.
properties:
id:
type: string
description: Meta ad ID (Instagram only).
title:
type: string
description: Ad creative title (Instagram only).
promotionStatus:
type: string
description: |
Facebook promotion status returned by Graph API. Common values:
"active" (organic post currently boosted), "ineligible" (dark
post or ad creative, not promotable because it already is an ad).
attachment:
type: object
description: |
Facebook only. Present on graphic-only comments (sticker, GIF, photo) that
carry no text. URLs are ephemeral and may expire for Meta platforms (oe= expiry),
so fetch promptly. Instagram comments do not support attachments.
required: [type]
properties:
type:
type: string
description: 'Attachment type: sticker, animated_image_share, or photo.'
imageUrl:
type: string
description: Rendered image/preview URL (from attachment.media.image.src).
url:
type: string
description: 'Source URL (from attachment.url). For GIFs this is an l.facebook.com redirect.'
post:
type: object
required: [id, platformPostId, 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.
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
text or rating, or when a reply is posted through POST /v1/inbox/reviews/{reviewId}/reply.
A reply written
directly in Google's own interface does NOT fire this event: Google emits no
notification when a reviewReply is written. Same shape as review.new. When a reply
is present, review.hasReply is true and review.reply is populated.
required: [id, event, review, account, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [review.updated]
review:
$ref: '#/components/schemas/ReviewWebhookReview'
account:
type: object
required: [id, platform, username]
properties:
id:
type: string
accountId:
type: string
description: 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 /
post.external.deleted. Fired by Zernio's background sync when it detects a
natively-authored post (e.g. a Google Business Profile localPost created in
the Google UI), NOT a post published through Zernio. Poll-driven (~hourly),
not real-time. On post.external.deleted, post.deletedAt is populated.
required: [id, event, post, account, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [post.external.created, post.external.updated, post.external.deleted]
post:
$ref: '#/components/schemas/ExternalPostWebhookPost'
account:
type: object
required: [id, platform, username]
properties:
id:
type: string
accountId:
type: string
description: 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.'
# ─── Shared sub-schemas for inbox lifecycle webhook payloads ────────────
# WebhookPayloadMessageEdited / Deleted / DeliveryStatus reference these
# as $refs. They were added as top-level named schemas (rather than nested
# refs into WebhookPayloadMessage.properties) because openapi-typescript
# and datamodel-code-generator can't resolve nested-property refs, so the
# SDK regen fails with "Cannot find name 'WebhookPayloadMessage_properties_message'".
InboxWebhookMessage:
type: object
description: The message object included in inbox webhook payloads.
required: [id, conversationId, platform, platformMessageId, direction, text, attachments, sender, sentAt, isRead]
properties:
id:
type: string
description: Internal message ID
conversationId:
type: string
description: Internal conversation ID
platform:
type: string
enum: [instagram, facebook, telegram, whatsapp, 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
platform: inbound WhatsApp media points at the authenticated
`GET /v1/whatsapp/media/{mediaId}` and requires
`Authorization: Bearer <your API key>`, while outgoing media carries the
URL originally supplied and Instagram / Facebook / Telegram carry direct
platform CDN links that need no authentication.
payload:
type: object
description: Additional attachment metadata
sender:
type: object
required: [id]
properties:
id:
type: string
description: |
Sender's platform identifier. For WhatsApp this is the phone number
(without leading `+`) when available, otherwise the `businessScopedUserId`.
For other platforms, the platform's own user ID.
contactId:
type: string
description: |
Zernio CRM Contact id for this sender, when one exists (joined via
the ContactChannel mapping). Lets integrators link a message straight
to a Contact without a follow-up Contacts API call. Omitted when the
sender isn't a tracked contact (e.g. outgoing messages where the
sender is the business, or first-touch messages before the contact
is created).
name:
type: string
username:
type: string
picture:
type: string
phoneNumber:
type: [string, "null"]
description: |
WhatsApp only. Sender's phone number in E.164 format (with leading `+`).
**Nullable during the BSUID rollout (April 2026+).** WhatsApp users
who adopt a username can message businesses without exposing a phone
number, so this field is omitted for them. Match by `businessScopedUserId`
instead. See `docs/whatsapp-bsuid-migration.md`.
businessScopedUserId:
type: string
description: |
WhatsApp only. Business-scoped user ID (BSUID), Meta's canonical
identifier for a WhatsApp user within your business. Present when
Meta includes it in the inbound payload (rollout in progress since
early April 2026). **Recommended primary identity anchor** going
forward; fall back to `phoneNumber` only when this field is absent.
parentBusinessScopedUserId:
type: string
description: |
WhatsApp only. Parent BSUID for businesses with linked business
portfolios. Omitted for standalone portfolios.
whatsappUsername:
type: string
description: |
WhatsApp only. User's WhatsApp username (e.g. `@jane`). Not a
stable identifier, because users can change it. Useful for display, not
recommended as an identity anchor.
instagramProfile:
type: object
description: Instagram profile data. Only present for Instagram conversations.
properties:
isFollower: { type: [boolean, "null"] }
isFollowing: { type: [boolean, "null"] }
followerCount: { type: [integer, "null"] }
isVerified: { type: [boolean, "null"] }
sentAt:
type: string
format: date-time
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
joining `participantId` to the ContactChannel collection. Best-effort:
omitted when no channel matches or `participantId` is absent. Lets
integrators join any inbox webhook back to the CRM Contact without
needing to look at the sender, which matters for outgoing and
delivery-status events whose sender is the business.
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
joining `participantId` to the ContactChannel collection (same join
used by message.*, reaction.received, and call.* webhooks). Best-effort:
omitted when no channel matches or `participantId` is absent. Lets
integrators seed the CRM straight from `conversation.started` without
waiting for the first `message.*` event.
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
`removed` on WhatsApp (Meta does not report which emoji was removed).
action:
type: string
enum: [added, removed]
messageId:
type: string
description: Internal Zernio message ID of the reacted-to message, when resolvable from the platform ID.
platformMessageId:
type: string
description: Platform-native ID of the reacted-to message (e.g. WhatsApp wamid).
sender:
type: object
required: [id]
description: 'Whoever added or removed the reaction. Usually the participant, but on 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
`metadata.referral` on `message.received`: `ref` + `source` for
ig.me / m.me links, `ad_id` + `ads_context_data` for returning
Messenger ad clicks.
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
from `type`. Meta's own attachment type before Zernio normalized
it: `ig_reel` and `reel` become `video`, while `ig_post`, `post`,
`ig_story` and `story_mention` all become `share`.
Read it before rendering, because `type: "share"` alone is
ambiguous. In particular a story mention arrives as
`type: "share"` with `originalType: "story_mention"`; treating an
unrecognized type as a generic document shows your agent
"document received" for what is usually a lead.
url:
type: string
description: |
Where to fetch the attachment. **The contract differs by platform.**
- **WhatsApp**: points at `GET /v1/whatsapp/media/{mediaId}`, an
authenticated Zernio endpoint. You MUST send
`Authorization: Bearer <your API key>`; fetching it without that
header returns `401`. Download and store the bytes when this
webhook arrives: Meta drops inbound media after a limited
retention window, after which the endpoint answers `400`
permanently and the media is unrecoverable.
- **Instagram / Facebook / Telegram**: a direct platform CDN link
that needs no authentication and expires on the platform's own
schedule.
**Webhook attachments carry no `refreshUrl`.** That field is
stamped only when you read a message back over REST
(`GET /v1/inbox/conversations/{conversationId}/messages`). On
Instagram and Facebook the url above is a signed Meta CDN link
that expires, so do not persist it: store the message id and
resolve the media through
`GET /v1/inbox/conversations/{conversationId}/messages/{messageId}/attachments/{index}?accountId={accountId}`,
which re-mints it on demand. Every value that URL needs is
already in this payload: `message.conversationId`,
`message.platformMessageId`, `account.accountId`, and the
attachment's zero-based position in this array.
payload:
type: object
description: Additional attachment metadata
sender:
type: object
required: [id]
properties:
id:
type: string
description: |
Sender's platform identifier. For WhatsApp this is the phone
number (without leading `+`) when available, otherwise the
`businessScopedUserId`.
contactId:
type: string
description: Zernio CRM Contact id for this sender, when one exists (omitted for outgoing/business sender).
name:
type: string
username:
type: string
picture:
type: string
phoneNumber:
type: [string, "null"]
description: |
WhatsApp only. Sender's phone number in E.164 format (with leading `+`).
**Nullable during the BSUID rollout (April 2026+).** WhatsApp
users who adopt a username can message businesses without
exposing a phone number, so this field is omitted for them.
Match by `businessScopedUserId` instead. See
`docs/whatsapp-bsuid-migration.md`.
businessScopedUserId:
type: string
description: |
WhatsApp only. Business-scoped user ID (BSUID), Meta's canonical
identifier for a WhatsApp user within your business. Present
when Meta includes it in the inbound payload (rollout in
progress since early April 2026). **Recommended primary identity
anchor** going forward; fall back to `phoneNumber` only when
this field is absent.
parentBusinessScopedUserId:
type: string
description: |
WhatsApp only. Parent BSUID for businesses with linked business
portfolios. Omitted for standalone portfolios.
whatsappUsername:
type: string
description: |
WhatsApp only. User's WhatsApp username (e.g. `@jane`). Not a
stable identifier, because users can change it. Useful for display,
not recommended as an identity anchor.
instagramProfile:
type: object
description: Instagram profile data for the sender. Only present for Instagram conversations.
properties:
isFollower:
type: [boolean, "null"]
description: Whether the sender follows your Instagram business account
isFollowing:
type: [boolean, "null"]
description: Whether your Instagram business account follows the sender
followerCount:
type: [integer, "null"]
description: The sender's follower count on Instagram
isVerified:
type: [boolean, "null"]
description: Whether the sender is a verified Instagram user
sentAt:
type: string
format: date-time
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
always `null` on this event, since nobody on our side produced an
inbound message; it is only informative on `message.sent`, which
documents the vocabulary.
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
Facebook Messenger `reply_to.mid`) of the message this one is a
quote-reply to, forwarded verbatim. It may not equal the stored
id of that message (see `quotedMessage.platformMessageId`). On
outgoing messages the same field appears on `message.sent`, but
only on some surfaces: see
WebhookPayloadMessageSent.metadata.quotedMessageId.
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:
`button_reply` (tap on an interactive button), `list_reply` (tap on a
list row), or `nfm_reply` (a WhatsApp Flow submission or an
`address_message` submission, see `nfmReplyName`).
interactiveId:
type: string
description: |
WhatsApp only. The `id` of the tapped button or list row, matching the
`id` you supplied when the message was sent. Not set for Flow responses.
buttonPayload:
type: string
description: |
WhatsApp only. Payload attached to a tapped template button. Template
buttons emit a plain `button` webhook (not an interactive reply), so
`interactiveType` is empty while this field is populated.
flowResponseJson:
type: string
description: |
WhatsApp only. Raw `nfm_reply.response_json` string returned by a
Flow submission. Useful if you need the exact wire payload; for
typed access use `flowResponseData` instead.
flowResponseData:
type: object
additionalProperties: true
description: |
WhatsApp only. Parsed Flow response JSON. Populated when
`flowResponseJson` is valid JSON; otherwise omitted. Keys and
value types depend on the specific Flow that was submitted. An
`address_message` submission (`nfmReplyName: address_message`) carries
the address fields (`name`, `address`, `city`, `state`, `in_pin_code`,
...), either at the top level or nested under `values`; read both.
nfmReplyName:
type: string
description: |
WhatsApp only. `nfm_reply.name` as Meta sent it, e.g. `flow` or
`address_message`. Address submissions share the `nfm_reply`
envelope with Flow submissions and are otherwise indistinguishable
in `flowResponseData`; use this field to tell them apart.
order:
type: object
description: |
WhatsApp only. Cart submitted by the user from a commerce message
(catalog, product, or product-list message). Meta's `order` object
forwarded verbatim.
properties:
catalog_id:
type: string
description: Meta catalog the ordered products belong to.
text:
type: string
description: Optional free-text note the user attached to the cart.
product_items:
type: array
items:
type: object
properties:
product_retailer_id:
type: string
description: Retailer ID (SKU) of the ordered product.
quantity:
type: integer
description: Quantity ordered for this line item.
item_price:
type: number
description: Unit price of the item.
currency:
type: string
description: ISO 4217 currency code (e.g. USD).
referredProduct:
type: object
description: |
WhatsApp only. The product the user is asking about. Set when an
inbound text carries Meta's `context.referred_product` (the user
tapped "Message business" on a product). Forwarded verbatim.
properties:
catalog_id:
type: string
description: Meta catalog the product belongs to.
product_retailer_id:
type: string
description: Retailer ID (SKU) of the product being asked about.
location:
type: object
description: |
WhatsApp only. The location pin the user shared, forwarded
verbatim from Meta. The message `text` is only the emoji preview
(`📍 <name>`); the coordinates live here.
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
from Meta. Read `contactsOrigin` before treating any number here
as the sender's own.
items:
type: object
additionalProperties: true
contactsOrigin:
type: string
enum: [contact_request, other]
description: |
WhatsApp only. How the contact card was shared.
`contact_request` means the user tapped a `request_contact_info`
button, so the number is their own and consented. `other` means
they picked a card from their address book: it may be anyone's,
and must NOT be stored as the sender's identity. Omitted when
Meta sends no origin.
storyReply:
type: object
description: |
Instagram only. Populated when an IG user replies to one of the
account's stories (Meta `messaging_story_replies`). Mutually
exclusive in practice with `isStoryMention`.
required: [storyId]
properties:
storyId:
type: string
description: The Instagram story ID the user replied to.
storyUrl:
type: string
description: |
Meta CDN URL for the story media. Expires approximately
24 hours after the story posted; consumers must fetch
promptly or treat 404s as expected.
isStoryMention:
type: boolean
description: |
Instagram only. True when the message was generated by an IG
user mentioning the account in their own story (`story_mention`
attachment type). Mutually exclusive in practice with `storyReply`.
referral:
type: [object, "null"]
description: |
Click attribution forwarded verbatim from Meta. Populated only on
the FIRST inbound message after the click; absent on subsequent
messages of the same conversation. On Instagram and Messenger a
RETURNING click also attaches it to the first message that
follows, so read it on every `message.received` for per-click
attribution; a click that opens an existing thread WITHOUT a
message arrives as the separate `referral.received` event.
The populated subset identifies the source:
- `ctwa_clid` and `source_*` fields: WhatsApp CTWA
(Click-to-WhatsApp). Attribution window is 7 days from click.
Forward to Meta Conversions API for Business Messaging replay.
- `ad_id` and `ads_context_data`: Facebook Messenger CTM
(Click-to-Message) or Instagram CTD (Click-to-Direct). Use
`ad_id` to attribute the conversation to a specific ad.
- `ref` without `ad_id`: an ig.me / m.me link carrying a
`?ref=` parameter (`source` is `SHORTLINK`, `SHORTLINKS` or
`IGME-SOURCE-LINK` depending on surface - treat it as
opaque). Instagram delivers ig.me refs on new threads only
when the account has at least one Ice Breaker configured
(`PUT /v1/accounts/{accountId}/instagram-ice-breakers`).
properties:
ctwa_clid:
type: string
description: Meta's GCLID-equivalent click identifier.
source_id:
type: string
source_type:
type: string
source_url:
type: string
headline:
type: string
body:
type: string
media_type:
type: string
image_url:
type: string
video_url:
type: string
thumbnail_url:
type: string
ad_id:
type: string
description: |
Facebook Messenger CTM / Instagram CTD only. The Meta ad ID the
user clicked to start the conversation.
ref:
type: string
description: |
The `ref` parameter passed through from the Meta ad creative
or from an ig.me / m.me link. Instagram / Facebook Messenger
only.
source:
type: string
description: |
Meta-supplied source identifier (`ADS` for ad clicks;
`SHORTLINK`, `SHORTLINKS` or `IGME-SOURCE-LINK` for ref
links). Instagram / Facebook Messenger only.
type:
type: string
description: |
Meta-supplied referral type (e.g. `OPEN_THREAD`). Instagram /
Facebook Messenger only.
referer_uri:
type: string
description: |
URI of the originating site, when Meta supplies one (m.me
links opened from the web). Facebook Messenger only.
ads_context_data:
type: object
description: |
Snapshot of the ad's public context at click time. Facebook
Messenger CTM / Instagram CTD only.
properties:
ad_title:
type: string
photo_url:
type: string
video_url:
type: string
post_id:
type: string
product_id:
type: string
flow_id:
type: string
unsupported:
type: object
description: |
WhatsApp only. Meta's own reason this message has no
renderable body. Present when Meta attached an error to the
inbound payload; in practice the `unsupported`, `errors` and
`unknown` types (code 131051: message type currently not
supported). `text` on those messages is the fixed
`[Unsupported message]` placeholder.
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
carries nothing an integrator can render (a `template`
attachment with no text and no parseable content, or Meta's
own `is_unsupported` flag). Sibling of `unsupported` above
(WhatsApp only, carries Meta's error code/title/details): this
field has no error envelope, only the boolean. Absence means
"not flagged", never "checked and renderable".
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
media URL as sent, so for WhatsApp it is the URL you supplied when
publishing (WhatsApp sends media by link), not a Zernio endpoint,
and it needs no Zernio credentials. Contrast the inbound direction:
`message.received` attachment URLs on WhatsApp point at the
authenticated `GET /v1/whatsapp/media/{mediaId}`.
As on `message.received`, webhook attachments carry no
`refreshUrl`: that field is stamped only on the REST read. Resolve
Instagram and Facebook media through
`GET /v1/inbox/conversations/{conversationId}/messages/{messageId}/attachments/{index}?accountId={accountId}`.
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
are talking to.** `id` is the Zernio account id and `name`,
`username` and `picture` are that connected account's own profile.
Do not read these to name or update a contact: doing so on an echo
relabels the customer's record with your business name. The other
party is `conversation.participantId` / `participantName` /
`participantUsername`, which are populated in both directions.
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
in the Zernio inbox), `api` (a call to this API), `broadcast`,
`sequence`, `workflow`, `comment_automation`, or `bulk-api`
(POST /v1/whatsapp/bulk). Same vocabulary as the `source` filter
on the inbox analytics endpoints, and the same value a later
GET on this message returns.
Always present, and `null` whenever the lineage is unknown: a
message sent from the platform's own app, and every message
stored before this field shipped (2026-08). Existing messages
are NOT backfilled, so treat `null` as "unknown", never as
"sent by a human".
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
shape the inbox send API accepts. Present on API sends that passed
`location`, and on Coexistence echoes of a pin shared from the
WhatsApp Business app. The message `text` is only the emoji
preview (`📍 <name>`); the pin itself lives here.
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
sends this is the `contacts` array exactly as given to the inbox
send API (`name`, `phones[].phone` / `type`, `emails[]`); on
Coexistence echoes of a card shared from the WhatsApp Business
app it is Meta's shape (`phones[].wa_id`, `vcard`). The message
`text` is only the emoji preview (`👤 <name>`); the cards live
here.
items:
type: object
additionalProperties: true
quotedMessageId:
type: string
description: |
`platformMessageId` of the message this send is a quote-reply to.
Present when the reply was sent through Zernio with `replyTo` on
the inbox send API (WhatsApp and Telegram). A WhatsApp API send
fires its `message.sent` off the delivery status, and the quote
reference is forwarded from the stored send there, so it arrives
on the same `message.sent` as any other WhatsApp send.
Not delivered on Instagram echoes. Zernio forwards
`reply_to.mid` whenever Meta puts it on an echo, but on
Instagram Meta does not send it, so a reply the operator quoted
in the Instagram app arrives with no `quotedMessageId`.
Facebook Messenger rides a separate subscription
(`message_echoes`) and has not been measured, so treat it as
unverified rather than supported.
Absent on WhatsApp Coexistence echoes. Meta omits the quote
context from `smb_message_echoes`, so a reply the operator sent
from the WhatsApp Business app arrives with no `quotedMessageId`
even though WhatsApp shows it as a quote-reply. Do not read the
absence of this field as "not a reply".
threadTs:
type: string
description: |
Slack only. Parent thread ts of the sent message. Pass it back as
`replyTo` on the inbox send API to keep replying inside the thread.
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
edits a previously-sent message. Supported platforms: Instagram,
Facebook Messenger, Telegram, WhatsApp. The message object reflects the
LATEST state; editHistory contains every prior version in order
(oldest first), so the last entry is the version immediately before
the current content.
required: [id, event, message, editHistory, editCount, editedAt, conversation, account, timestamp]
properties:
id: { type: string }
event: { type: string, enum: [message.edited] }
message:
$ref: '#/components/schemas/InboxWebhookMessage'
editHistory:
type: array
description: Prior versions of the message, oldest first.
items:
$ref: '#/components/schemas/InboxMessageEditHistoryEntry'
editCount:
type: integer
description: Total number of edits applied to this message.
editedAt:
type: string
format: date-time
description: When the most recent edit happened.
conversation:
$ref: '#/components/schemas/InboxWebhookConversation'
account:
$ref: '#/components/schemas/InboxWebhookAccount'
timestamp:
type: string
format: date-time
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
deletes (unsends) a message. Supported platforms: Instagram (incoming
unsend) and WhatsApp, in both directions: an outgoing message the
business deleted (via the Cloud API, or from the WhatsApp Business app
on a Coexistence number) and an incoming message the customer deleted.
Read `message.direction` to tell the two apart.
The message.text and message.attachments fields retain the content
that existed before the delete. The Zernio dashboard UI does not show
this content, but authorized API consumers may access it for
moderation, compliance, or archival use cases.
required: [id, event, message, deletedAt, conversation, account, timestamp]
properties:
id: { type: string }
event: { type: string, enum: [message.deleted] }
message:
$ref: '#/components/schemas/InboxWebhookMessage'
deletedAt:
type: string
format: date-time
conversation:
$ref: '#/components/schemas/InboxWebhookConversation'
account:
$ref: '#/components/schemas/InboxWebhookAccount'
timestamp:
type: string
format: date-time
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
message.failed events. Fires when the platform reports a new
delivery state for an outgoing message.
Platform support:
* message.delivered: WhatsApp, Facebook Messenger, SMS.
* message.read: WhatsApp, Facebook Messenger, Instagram. Not SMS
(carriers report delivery, never read).
* message.failed: WhatsApp and SMS (other platforms don't expose
per-message failure via webhook). On SMS, `error.code` is the
carrier's numeric code and `error.message` its reason.
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
recipient has likely opted out of marketing messages while utility
templates are unaffected, or for 131031, that Meta restricted the
WhatsApp Business Account). Null for unmapped codes; fall back to
title/message.
conversation:
$ref: '#/components/schemas/InboxWebhookConversation'
account:
$ref: '#/components/schemas/InboxWebhookAccount'
timestamp:
type: string
format: date-time
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
starts the first time an account and a contact exchange a message on any DM
platform (Instagram, Messenger/Facebook, Telegram, WhatsApp, X, Reddit,
Bluesky, SMS). Platform-agnostic: one subscription covers every DM platform.
required: [id, event, conversation, account, startedAt, timestamp]
properties:
id: { type: string, description: Stable webhook event ID }
event:
type: string
enum: [conversation.started]
conversation:
$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,
handed it to you, or another partner app took it.
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
lane it took: `whatsapp` (WhatsApp Business Calling) or `pstn` (a regular
phone call). List endpoints omit `transcript`; use `lastTranscriptSnippet`
for a preview and the detail endpoint for the full transcript.
properties:
_id: { type: string }
accountId: { type: string, description: "Owning account. The unified /v1/calls/{id} detail + recording endpoints work for any channel; the channel-specific endpoints remain for account-scoped access." }
conversationId: { type: [string, "null"], description: "Inbox conversation with the counterparty, when one exists." }
contactId: { type: [string, "null"], description: "CRM Contact for the counterparty, when resolved." }
channel: { type: string, enum: [whatsapp, pstn] }
direction: { type: string, enum: [inbound, outbound] }
from: { type: string, description: "Caller number (E.164)." }
to: { type: string, description: "Callee number (E.164)." }
forwardTo: { type: [string, "null"], description: "Destination the call was routed to (tel:/sip:/wss:), snapshotted at routing time." }
greeting: { type: [string, "null"], description: "Outbound PSTN only. Message spoken to the callee on answer, before the bridge." }
status: { type: string, enum: [ringing, answered, ended, failed] }
isVoicemail: { type: boolean, description: True when an inbound call went to voicemail. }
amd: { type: boolean, description: Outbound answering-machine detection was requested for this call. }
answeredMachine: { type: [boolean, "null"], description: "With `amd`, whether a machine (vs a human) answered." }
forwardCallerId: { type: string, enum: [business, caller], description: Caller ID presented on the forwarded leg. }
recordingEnabled: { type: boolean, description: "Effective flag for THIS call (number default + per-call override, resolved at create time)." }
transcriptionEnabled: { type: boolean }
transcriptionLanguage: { type: string, enum: [auto, en, es] }
startedAt: { type: string, format: date-time }
answeredAt: { type: [string, "null"], format: date-time }
endedAt: { type: [string, "null"], format: date-time }
transferredAt: { type: [string, "null"], format: date-time, description: "When the call was blind-transferred (POST /v1/voice/calls/{id}/transfer)." }
durationSeconds: { type: integer }
endReason: { type: string, enum: [hangup, no_answer, rejected, error] }
hangupCause: { type: [string, "null"], description: "Raw carrier hangup cause behind endReason (e.g. normal_clearing, not_found, time_limit). The actual motive when endReason is a coarse bucket." }
sipHangupCause: { type: [string, "null"], description: "SIP response code that ended the call, when SIP-signalled (e.g. '403', '488'). The real failure reason for SIP legs." }
callErrors:
type: array
description: Per-call failure log (dial failed, bridge failed, recording error).
items:
type: object
properties:
code: { type: integer }
message: { type: string }
recordingUrl: { type: [string, "null"], description: "May be expired. Resolve a fresh playable URL via GET /v1/calls/{id}/recording (any channel)." }
lastTranscriptSnippet: { type: [string, "null"], description: "Most recent transcript segment, for list previews." }
transcript:
type: array
description: Full transcript segments (detail endpoint only; omitted from lists).
items:
type: object
properties:
text: { type: string }
confidence: { type: number }
at: { type: string, format: date-time }
billing:
type: object
properties:
metaMinutes: { type: number }
telnyxSeconds: { type: number }
transcriptionSeconds: { type: number }
transcriptionCostUSD: { type: number }
metaCostUSD: { type: number, description: 'WhatsApp channel only. Meta per-minute charge, billed by Meta directly to your WABA. Display only; not billed by Zernio.' }
telnyxCostUSD: { type: number }
recordingCostUSD: { type: number }
billableCostUSD: { type: number, description: 'Amount Zernio bills you = telephony leg + recording + transcription (excludes any Meta portion).' }
totalCostUSD: { type: number, description: 'Full cost incl. any Meta portion you pay directly. Display only.' }
currency: { type: string }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
WebhookPayloadCallReceived:
type: object
description: |
Webhook payload for the `call.received` event. Fires for both
inbound (UIC) and outbound (BIC) calls; branch on
`call.direction` to tell them apart.
required: [id, event, call, account, timestamp]
properties:
id: { type: string, description: Stable webhook event ID }
event:
type: string
enum: [call.received]
call:
type: object
properties:
id: { type: string, description: Internal Zernio Call doc id }
metaCallId: { type: [string, "null"], description: "Meta wacid.* call id when known" }
accountId: { type: string }
phoneNumberId: { type: string, description: "Meta phone_number_id" }
direction: { type: string, enum: [inbound, outbound] }
from: { type: string, description: "Consumer wa_id / E.164" }
to: { type: string, description: "Business number (E.164)" }
forwardTo: { type: string, description: "Destination snapshot at routing time" }
contactId: { type: string }
conversationId: { type: string }
startedAt: { type: string, format: date-time }
account: { $ref: '#/components/schemas/InboxWebhookAccount' }
timestamp:
type: string
format: date-time
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
with the duration and a zero-markup billing breakdown.
required: [id, event, call, account, timestamp]
properties:
id: { type: string }
event:
type: string
enum: [call.ended]
call:
type: object
properties:
id: { type: string }
metaCallId: { type: [string, "null"] }
accountId: { type: string }
phoneNumberId: { type: string }
direction: { type: string, enum: [inbound, outbound] }
from: { type: string }
to: { type: string }
startedAt: { type: string, format: date-time }
endedAt: { type: string, format: date-time }
durationSeconds: { type: integer }
endReason: { type: string, enum: [hangup, no_answer, rejected, error] }
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
setup or in-progress call fails.
required: [id, event, call, account, timestamp]
properties:
id: { type: string }
event:
type: string
enum: [call.failed]
call:
type: object
properties:
id: { type: string }
metaCallId: { type: [string, "null"] }
accountId: { type: string }
phoneNumberId: { type: string }
direction: { type: string, enum: [inbound, outbound] }
from: { type: string }
to: { type: string }
failedAt: { type: string, format: date-time }
error:
type: object
properties:
code: { type: integer }
message: { type: string }
account: { $ref: '#/components/schemas/InboxWebhookAccount' }
timestamp:
type: string
format: date-time
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
when a consumer accepts or rejects an interactive
`call_permission_request` message.
required: [id, event, permission, account, timestamp]
properties:
id: { type: string }
event:
type: string
enum: [call.permission_request]
permission:
type: object
properties:
from: { type: string, description: "Consumer wa_id who replied" }
response: { type: string, enum: [accept, reject] }
isPermanent: { type: boolean }
expirationTimestamp: { type: string, format: date-time, description: "Present only when temporary" }
responseSource: { type: string, description: "Meta's response source, typically `user_action`" }
account: { $ref: '#/components/schemas/InboxWebhookAccount' }
timestamp:
type: string
format: date-time
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
only for Meta (`metaads`).
Sourced from two Meta `ad_account` webhook fields:
- `in_process_ad_objects` - the ad object finished processing and
exited `IN_PROCESS`. `status.raw` carries Meta's `status_name`.
- `with_issues_ad_objects` - the ad object entered `WITH_ISSUES`.
`status.raw` is `WITH_ISSUES` and the `error` block is populated
from Meta's `error_code` / `error_summary` / `error_message`.
required: [id, event, account, adObject, status, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [ad.status_changed]
account:
type: object
description: The connected ad-platform account that owns the ad object.
required: [accountId, profileId, platform, username]
properties:
accountId:
type: string
description: Internal Zernio account ID (same as used in /v1/accounts/{accountId}).
profileId:
type: string
description: Internal Zernio profile ID this account belongs to.
platform:
type: string
description: Ad platform identifier. Currently always `metaads`.
example: metaads
username:
type: string
description: Display username of the connected ad-platform account.
displayName:
type: string
description: Human-readable display name of the account, when available.
adObject:
type: object
description: The ad-platform object the status change applies to.
required: [level, platformId, platformAdAccountId]
properties:
level:
type: string
enum: [CAMPAIGN, AD_SET, AD]
description: Hierarchy level the status applies to. Mirrors Meta's `level`. Creative-level events are not forwarded.
platformId:
type: string
description: |
Platform-native ID of the campaign / ad set / ad. For Meta this is
the bare numeric ID (e.g. `120244894077860689`).
example: "120244894077860689"
platformAdAccountId:
type: string
description: |
Platform-native ad-account ID. For Meta this uses the `act_<id>`
shape.
example: act_2129800524463520
status:
type: object
description: Status info. Branch on `status.raw` to handle each transition.
required: [raw]
properties:
raw:
type: string
description: |
Platform-native status string, forwarded verbatim. For Meta
this is `status_name` from `in_process_ad_objects` (e.g.
`ACTIVE`, `PAUSED`, `PENDING_REVIEW`, `ARCHIVED`, `DELETED`,
`DISAPPROVED`), or `WITH_ISSUES` when sourced from
`with_issues_ad_objects`. Not constrained by an `enum`, because Meta
may add new values.
example: ACTIVE
error:
type: object
description: |
Optional. Present on most `WITH_ISSUES` events, carrying the
platform's error diagnostics. May be absent on some `WITH_ISSUES`
events (Meta does not always include diagnostics). Always absent
for any other `status.raw` value. Always null-check before reading.
required: [code]
properties:
code:
type: string
description: |
Platform-native error code, forwarded verbatim. For Meta this
is `error_code` as a string. Use as the stable discriminator, since
`summary` and `message` are localized.
example: "2643001"
summary:
type: string
description: |
Short human-readable summary (Meta `error_summary`). Localized
to the ad-account owner's Meta locale. Display only, do not
match on it.
example: Ad Processing Error
message:
type: string
description: |
Full human-readable error message (Meta `error_message`).
Localized, display only.
timestamp:
type: string
format: date-time
description: '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.
Fired when Meta completes (re)review of a template attached to a
connected WABA. Maps Meta's `message_template_status_update` field
onto our event envelope.
required: [id, event, account, template, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [whatsapp.template.status_updated]
account:
type: object
required: [accountId, profileId, platform, username]
properties:
accountId: { type: string }
profileId: { type: string }
platform: { type: string, enum: [whatsapp] }
username: { type: string }
displayName: { type: string }
template:
type: object
required: [templateId, name, language, status, reason]
properties:
templateId:
type: string
description: Meta's `message_template_id`, returned as a string.
name:
type: string
description: Meta's `message_template_name`.
language:
type: string
description: Meta's `message_template_language` (e.g. `en_US`).
status:
type: string
enum: [APPROVED, REJECTED, PENDING, PAUSED, DISABLED, IN_APPEAL, PENDING_DELETION]
description: |
New status. Forwarded verbatim from Meta's `event` field.
`PENDING_DELETION` is the 24h-grace state after a delete
request before the template is actually removed.
reason:
type: string
description: |
Meta's free-form reason for the transition. `"NONE"` on
approval; an explanation string on rejection.
timestamp:
type: string
format: date-time
description: '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.
Fired when Meta reclassifies a template's category attached to a
connected WABA. Maps Meta's `template_category_update` field onto
our event envelope.
required: [id, event, account, template, timestamp]
properties:
id:
type: string
description: Stable webhook event ID
event:
type: string
enum: [whatsapp.template.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
reclassification; `applied` is the change taking effect.
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.
Fired when Meta finishes reviewing a WhatsApp display-name change on a
connected number. Maps Meta's `phone_number_name_update` WABA webhook
field onto our event envelope. Fires only for a review outcome
(APPROVED, DECLINED, PENDING_REVIEW); a name applied without review
reports `name_status: AVAILABLE_WITHOUT_REVIEW` on the phone node
instead, and Meta never sends this webhook field for that case.
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.
properties:
countries:
type: array
minItems: 1
maxItems: 25
items:
type: string
pattern: '^[A-Z]{2}$'
example: US
description: >
ISO 3166-1 alpha-2 country codes (uppercase). Only users in these countries can see the post.
Maximum 25 countries per post. Example: ["US", "CA", "GB", "ES"].
example: ["US", "CA", "GB"]
required:
- countries
MediaItem:
type: object
description: Media referenced in posts. URLs must be publicly reachable over HTTPS. Use POST /v1/media/presign for uploads up to 5GB. Zernio auto-compresses images and videos that exceed platform limits (videos over 200 MB may not be compressed).
properties:
type:
type: string
enum: [image, video, gif, document]
url:
type: string
format: uri
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.
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).
After the post is published, the system creates new scheduled copies at the
specified interval until expiration conditions are met. Supports weekly or
monthly intervals. Maximum 10 active recycling posts per account.
YouTube and TikTok platforms are excluded from recycling.
Content variations are recommended for X and Pinterest to avoid duplicate flags.
properties:
enabled:
type: boolean
default: true
description: Set to false to disable recycling on this post
gap:
type: integer
minimum: 1
description: Number of interval units between each repost. Required when enabling recycling.
example: 2
gapFreq:
type: string
enum: [week, month]
default: month
description: Interval unit for the gap. Defaults to 'month'.
startDate:
type: string
format: date-time
description: When to start the recycling cycle. Defaults to the post's scheduledFor date.
expireCount:
type: [integer, "null"]
minimum: 1
description: Stop recycling after this many copies have been created. Send null on update to clear this limit.
example: 5
expireDate:
type: [string, "null"]
format: date-time
description: Stop recycling after this date, regardless of count. Send null on update to clear this limit.
contentVariations:
type: array
items:
type: string
maxItems: 20
description: |
Array of content variations for recycled copies. On each recycle, the next
variation is used in round-robin order. Recommended for X and Pinterest
to avoid duplicate content flags. If omitted, the original post content is
used for all recycled copies. Send an empty array [] to clear existing
variations. Must have 2+ entries when setting variations. Platform-level
customContent still overrides the base content per platform.
RecyclingState:
type: object
description: Current recycling configuration and state on a post
properties:
enabled:
type: boolean
description: Whether recycling is currently active
gap:
type: integer
description: Number of interval units between reposts
gapFreq:
type: string
enum: [week, month]
description: Interval unit (week or month)
startDate: { type: string, format: date-time }
expireCount: { type: integer }
expireDate: { type: string, format: date-time }
contentVariations:
type: array
items:
type: string
description: Content variations for recycled copies (if configured)
contentVariationIndex:
type: integer
description: Current position in the content variations rotation (read-only)
recycleCount:
type: integer
description: How many recycled copies have been created so far (read-only)
nextRecycleAt:
type: string
format: date-time
description: When the next recycled copy will be created (read-only)
lastRecycledAt:
type: string
format: date-time
description: When the last recycled copy was created (read-only)
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.
Articles are mutually exclusive with top-level/custom tweet media and with threadItems,
poll, quoteTweetId, replyToTweetId, inReplyToTweetId, replySettings, sensitiveMedia,
paidPartnership, and madeWithAi. Publishing normally performs two billable X API
requests at $0.010 each (draft + publish, $0.020 total); mode `draft` performs only
the $0.010 draft request. `articleDraftId` is an internal recovery checkpoint and
must not be supplied by API clients.
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].
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.
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].
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.
FacebookSettings:
type: object
description: >
Facebook options that must be nested under platformSpecificData.facebookSettings, or sent
at the request root as facebookSettings. The remaining Facebook options sit directly on
platformSpecificData, see FacebookPlatformData.
properties:
draft:
type: boolean
description: When true, creates the post as a draft in Facebook Publishing Tools instead of publishing immediately. Supported for feed posts (text, link, image, video) and reels. Not supported for stories. Drafts expire after ~30 days.
default: false
carouselCards:
type: array
minItems: 2
maxItems: 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.
items:
type: object
required: [link]
properties:
link:
type: string
format: uri
description: Per-card click destination (required).
name:
type: string
maxLength: 255
description: Per-card headline (optional, ~35-char display).
description:
type: string
maxLength: 255
description: Per-card subhead (optional, ~30-char display).
carouselLink:
type: string
format: uri
description: >
Optional top-level "See more" destination shown on the carousel end card. Defaults
to the first card's link when omitted. Only used together with carouselCards.
textFormatPresetId:
type: string
pattern: '^\d+$'
description: >
Facebook-defined preset ID that renders the post as large text on a colored
background (Graph `text_format_preset_id`). Supply the raw numeric ID from Meta;
we do not publish a catalog of presets and Facebook may change the available set.
Pages only (ignored on personal profiles and groups) and text-only feed posts only:
the request is rejected with 400 when mediaItems or carouselCards are present, when
contentType is story or reel, or when content is empty. An attachment makes Facebook
drop the background silently, so those are rejected up front. Length is NOT rejected:
Facebook's composer stops offering a background at around 130 characters, but Meta
documents no API limit, so longer content publishes and returns a warning instead.
A URL detected in the content is NOT attached as a link preview while a preset is
set, because a link attachment also makes Facebook drop the background.
InstagramPlatformData:
type: object
properties:
contentType:
type: string
enum: [story]
description: Set to 'story' to publish as a Story. Default posts become Reels or feed depending on media.
shareToFeed:
type: boolean
default: true
description: For Reels only. When true (default), the Reel appears on both the Reels tab and your main profile feed. Set to false to post to the Reels tab only.
collaborators:
type: array
items: { type: string }
description: Up to 3 Instagram usernames to invite as collaborators (feed/Reels only)
firstComment:
type: string
description: Optional first comment to add after the post is created (not applied to Stories)
trialParams:
type: object
description: Trial Reels configuration. Trial reels are shared to non-followers first and can later be graduated to regular reels manually or automatically based on performance. Only applies to Reels.
properties:
graduationStrategy:
type: string
enum: [MANUAL, SS_PERFORMANCE]
description: "MANUAL (graduate from Instagram app) or SS_PERFORMANCE (auto-graduate if performs well with non-followers)"
userTags:
type: array
description: 'Tag Instagram users by username. The tag shape depends on the media: photos require x/y coordinates, Reels and videos take username only (coordinates are ignored), stories accept optional coordinates. For carousels, use mediaIndex to target specific slides (defaults to 0); video slides take username-only tags. Photo tags without valid coordinates are skipped.'
items:
type: object
required: [username]
properties:
username:
type: string
description: Instagram username (@ symbol is optional and will be removed automatically)
example: friend_username
x:
type: number
minimum: 0
maximum: 1
description: 'X coordinate position from left edge (0.0 = left, 0.5 = center, 1.0 = right). Required for photos, ignored for Reels/videos, optional for stories.'
example: 0.5
y:
type: number
minimum: 0
maximum: 1
description: 'Y coordinate position from top edge (0.0 = top, 0.5 = center, 1.0 = bottom). Required for photos, ignored for Reels/videos, optional for stories.'
example: 0.5
mediaIndex:
type: integer
minimum: 0
description: Zero-based index of the carousel item to tag. Defaults to 0. Tags on out-of-range indices are ignored.
example: 0
audioName:
type: string
description: 'Custom name for original audio in Reels. Replaces the default "Original Audio" label. Can only be set once. 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.
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
`unitCost` is set so the manual bid applies to an explicit cost model.
unitCost:
type: number
description: |
Manual bid in WHOLE account-currency units (e.g. 2.5 = $2.50). Requires
`costType`. Omit for LinkedIn's automated (max delivery) bidding.
LinkedIn enforces its own per-audience min/max bid bounds.
optimizationTargetType:
type: string
description: |
Campaign `optimizationTargetType` (e.g. `MAX_CLICK`, `TARGET_COST_PER_CLICK`,
`MAX_IMPRESSION`). Forwarded verbatim, LinkedIn validates compatibility with
the objective and `costType`. Omit for the objective-derived default:
`awareness` gets `MAX_IMPRESSION`, `video_views` gets `MAX_VIDEO_VIEW`, and
every other goal gets `MAX_CLICK`. `lead_generation` and `conversions` also
get `MAX_CLICK`, because `MAX_LEAD` and `MAX_CONVERSION` need a lead gen form
or a conversion rule that neither creation flow attaches. The default applies
only to `SPONSORED_UPDATES` campaigns (every boost, and the image, video and
carousel standalone ads), never to the `TEXT_AD`, `DYNAMIC` and
`SPONSORED_INMAILS` campaigns the other creative formats produce. It is also
skipped when `unitCost` or a non-`CPM` `costType` is set, since those select
manual bidding and the bid is then yours to choose.
creativeSelection:
type: string
enum: [OPTIMIZED, ROUND_ROBIN]
description: How LinkedIn rotates creatives within the campaign. Defaults to `OPTIMIZED`.
audienceExpansionEnabled:
type: boolean
description: Enable LinkedIn audience expansion. Defaults to false.
offsiteDeliveryEnabled:
type: boolean
description: Deliver on the LinkedIn Audience Network. Defaults to false.
connectedTelevisionOnly:
type: boolean
description: Restrict delivery to Connected TV inventory.
carousel:
type: object
additionalProperties: false
required: [cards]
description: |
POST /v1/ads/create only. Carousel ad with 2-10 image cards.
Mutually exclusive with the other creative sources.
properties:
cards:
type: array
minItems: 2
maxItems: 10
items:
type: object
additionalProperties: false
required: [imageUrl]
properties:
imageUrl: { type: string, format: uri }
headline:
type: string
description: Card title. Falls back to the ad-level headline.
landingUrl:
type: string
format: uri
description: |
Per-card click destination. LinkedIn requires one on every
card; the ad-level `linkUrl` backfills cards that omit it.
document:
type: object
additionalProperties: false
required: [url, title]
description: |
POST /v1/ads/create only. Document ad rendered as an in-feed viewer.
PDF, PPT or DOC up to 100MB. Mutually exclusive with the other
creative sources.
properties:
url: { type: string, format: uri }
title:
type: string
description: Document title.
spotlight:
type: object
additionalProperties: false
required: [headline, callToAction, landingUrl]
description: |
POST /v1/ads/create only. Dynamic Spotlight Ad personalized with the
viewer's profile photo. Supported goals: traffic, awareness. logoUrl
and organizationName default to the Company Page's; set them
explicitly if LinkedIn rejects the create with a 404. Mutually
exclusive with the other creative sources.
properties:
headline: { type: string }
description:
type: string
description: Mutually exclusive with backgroundImageUrl.
callToAction:
type: string
description: Button label text.
landingUrl: { type: string, format: uri }
logoUrl: { type: string, format: uri }
organizationName: { type: string }
showMemberProfilePhoto: { type: boolean, description: Defaults to true. }
backgroundImageUrl:
type: string
format: uri
description: Custom background. Replaces the description and the profile photo.
follower:
type: object
additionalProperties: false
required: [headline, description, callToAction]
description: |
POST /v1/ads/create only. Dynamic Follower Ad promoting the Company
Page. Supported goals: engagement, awareness. headline and
description take exactly one of preApproved or custom. Mutually
exclusive with the other creative sources.
properties:
headline:
type: object
additionalProperties: false
properties:
preApproved:
type: string
description: LinkedIn preset id, not reviewed. Example GROW_YOUR_BUSINESS_INSIGHTS.
custom:
type: string
description: Free text, reviewed by LinkedIn.
description:
type: object
additionalProperties: false
properties:
preApproved:
type: string
description: LinkedIn preset id, not reviewed. Example GET_LATEST_JOBS_AND_INDUSTRY_NEWS.
custom:
type: string
description: Free text, reviewed by LinkedIn.
callToAction:
type: string
enum: [VISIT_ORGANIZATION_COMPANY_PAGE, VISIT_ORGANIZATION_LIFE_PAGE, VISIT_ORGANIZATION_JOBS_PAGE, VISIT_ORGANIZATION_CAREERS_PAGE]
logoUrl: { type: string, format: uri }
organizationName: { type: string }
showMemberProfilePhoto: { type: boolean, description: Defaults to true. }
jobs:
type: object
additionalProperties: false
required: [headline, buttonLabel]
description: |
POST /v1/ads/create only. Dynamic Jobs Ad promoting your open roles,
personalized with the viewer's profile photo. Requires goal
job_applicants and a Company Page with active job postings.
headline and buttonLabel take exactly one of
preApproved or custom. logoUrl and organizationName default to the
Company Page's. Mutually exclusive with the other creative sources.
properties:
headline:
type: object
additionalProperties: false
properties:
preApproved:
type: string
description: LinkedIn preset id, not reviewed. Example MEMBER_READY_FOR_YOUR_DREAM_JOB.
custom:
type: string
description: Free text, reviewed by LinkedIn.
buttonLabel:
type: object
additionalProperties: false
properties:
preApproved:
type: string
description: LinkedIn preset id, not reviewed. One of SEE_MORE_JOBS, VIEW_MORE, CAREERS_AT_COMPANY.
custom:
type: string
description: Free text, reviewed by LinkedIn.
logoUrl: { type: string, format: uri }
organizationName: { type: string }
showMemberProfilePhoto: { type: boolean, description: Defaults to true. }
textAd:
type: object
additionalProperties: false
required: [headline, description, landingUrl]
description: |
POST /v1/ads/create only. Classic right-rail Text Ad. The copy lives
here; ad-level body and headline are not used. Mutually exclusive
with the other creative sources.
properties:
headline:
type: string
maxLength: 25
description:
type: string
maxLength: 75
landingUrl:
type: string
format: uri
imageUrl:
type: string
format: uri
description: Optional 100x100 image.
conversation:
type: object
additionalProperties: false
required: [subject, firstMessageId, messages]
description: |
POST /v1/ads/create only. Conversation Ad: a choose-your-path message
tree delivered to the member's LinkedIn inbox. Messages are flat
nodes wired by local ids; each button either opens a url or leads to
nextMessageId. Cycles, unknown ids and a missing firstMessageId
return a 400. LinkedIn does not deliver message ads to EU members.
Mutually exclusive with the other creative sources.
properties:
subject:
type: string
description: InMail subject shown in the inbox.
sender:
type: string
description: |
Person or organization URN. Defaults to the authoring Company
Page. The sender must be approved for the ad account first
(Campaign Manager > Manage message ad senders) or LinkedIn
rejects the create with SINMAIL_SENDER_NOT_APPROVED.
body:
type: string
description: Optional intro body (HTML allowed).
footer:
type: string
description: Terms shown at the bottom of the message.
headline:
type: string
description: Conversation headline. Defaults to the first message's first line.
firstMessageId: { type: string }
messages:
type: array
minItems: 1
maxItems: 25
items:
type: object
additionalProperties: false
required: [id, text]
properties:
id: { type: string }
text: { type: string, maxLength: 500 }
buttons:
type: array
minItems: 1
maxItems: 5
items:
type: object
additionalProperties: false
required: [text]
properties:
text: { type: string, maxLength: 35 }
nextMessageId:
type: string
description: Continues the conversation at this message. Exactly one of nextMessageId or url.
url:
type: string
format: uri
description: Opens this landing page. Exactly one of nextMessageId or url.
event:
type: object
additionalProperties: false
required: [urn]
description: |
POST /v1/ads/create only. Promotes an existing LinkedIn Event; no
headline needed. Mutually exclusive with the other creative sources.
properties:
urn:
type: string
pattern: '^urn:li:event:\d+$'
description: LinkedIn Event URN, urn:li:event:N.
thoughtLeader:
type: object
additionalProperties: false
required: [postUrn]
description: |
POST /v1/ads/create only. Sponsors an existing LinkedIn post
(a share or ugcPost authored by your organization's Company
Page) as the creative, keeping its commentary, author and
engagement. Unlike boostPost, which provisions its own
CampaignGroup + Campaign around the post, this variant
attaches the reference under the campaign /v1/ads/create
builds, the same shape as every other format, so the caller can
pick bidding / targeting / schedule freely. No headline, body,
imageUrl or organization are needed; the referenced post
carries its own commentary and author. Mutually exclusive
with the other creative sources. Posts from personal profiles
(Thought Leader Ads) are NOT supported (see postUrn).
properties:
postUrn:
type: string
pattern: '^urn:li:(share|ugcPost):\d+$'
description: >
LinkedIn share or ugcPost URN, urn:li:share:N or
urn:li:ugcPost:N. Get it via "Copy link to post" on the
target LinkedIn post (the URL contains -share- for a share
or -ugcPost- for a ugcPost, then the numeric id).
The post must be authored by an organization (Company
Page). Member (personal profile) posts, i.e. Thought
Leader Ads proper, are rejected by LinkedIn's public
Marketing API regardless of sponsorship approval and of
post type (a LinkedIn limitation; their Campaign Manager
creates those through a private API). Referencing a
member post returns a 422 with a clear error.
description: >
LinkedIn-specific options for POST /v1/ads/boost and POST /v1/ads/create:
campaign bidding and delivery controls, plus the LinkedIn-only creative
formats on /v1/ads/create. Unknown keys are rejected.
PinterestPlatformData:
type: object
properties:
title:
type: string
maxLength: 100
description: Pin title. Defaults to first line of content or "Pin". Must be ≤ 100 characters.
boardId:
type: string
description: Target Pinterest board ID. If omitted, the first available board is used.
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.
privacyLevel must match creator_info options. Both camelCase and snake_case accepted.
Creator Inbox (draft mode): Set draft: true to send content to the TikTok Creator Inbox
instead of publishing immediately. The creator receives an inbox notification and completes
the post using TikTok's editing flow. This maps to TikTok's post_mode: "MEDIA_UPLOAD" internally.
Important: The field publish_type is NOT supported. Use draft: true for Creator Inbox flow.
Photo drafts use the /v2/post/publish/content/init/ endpoint with post_mode: "MEDIA_UPLOAD".
Video drafts use the dedicated /v2/post/publish/inbox/video/init/ endpoint.
When draft: true, the video.upload scope is required. When draft is false or omitted
(direct post), the video.publish scope is required. For Creator Inbox, TikTok app version
must be 31.8 or higher.
properties:
draft:
type: boolean
description: |
When true, sends the post to the TikTok Creator Inbox as a draft instead of publishing
immediately. The creator receives an inbox notification to complete posting via TikTok's
editing flow. Maps to TikTok API post_mode: "MEDIA_UPLOAD" (photos) or the dedicated
inbox endpoint (videos). When false or omitted, publishes directly via post_mode: "DIRECT_POST".
Note: publish_type is not a supported field. Use this field instead.
privacyLevel:
type: string
description: 'One of the values returned by the TikTok creator info API for the account. 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"
("Your Brand") implies isBrandOrganicPost and "brand_content" ("Branded Content",
paid partnership) implies brandPartnerPromote, so you don't need to send the
boolean flags separately. Branded content cannot be posted with privacyLevel
SELF_ONLY.
brandPartnerPromote:
type: boolean
description: |
Whether the post promotes a brand partner (branded content / paid partnership).
Only needed to disclose BOTH types at once (set it alongside
commercialContentType "brand_organic"), or to override the value implied by
commercialContentType.
isBrandOrganicPost:
type: boolean
description: |
Whether the post promotes the creator's own brand (brand organic). Only needed
to disclose BOTH types at once (set it alongside commercialContentType
"brand_content"), or to override the value implied by commercialContentType.
contentPreviewConfirmed:
type: boolean
description: User has confirmed they previewed the content
expressConsentGiven:
type: boolean
description: User has given express consent for posting
mediaType:
type: string
enum: [video, photo]
description: Optional override. Defaults based on provided media items.
videoCoverTimestampMs:
type: integer
description: Optional for video posts. Timestamp in milliseconds to select which frame to use as thumbnail (defaults to 1000ms/1 second). Ignored when videoCoverImageUrl is provided.
minimum: 0
videoCoverImageUrl:
type: string
format: uri
description: 'Optional for video posts. URL of a custom thumbnail image (JPG, PNG, or WebP, max 20MB). 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.
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.
threadItems:
type: array
description: >
Complete sequence of posts in a Bluesky thread. The first item becomes the root post,
subsequent items are chained as replies. When threadItems is provided, the top-level
content field is used only for display and search purposes, it is NOT published.
You must include your first post as threadItems[0].
items:
type: object
properties:
content:
type: string
mediaItems:
type: array
items:
$ref: '#/components/schemas/MediaItem'
description: |
Bluesky post settings. Supports text posts with up to 4 images or a single video. threadItems creates a reply chain (Bluesky thread). Images exceeding 1MB are automatically compressed. Alt text supported via mediaItem properties. Use langs to tag post language for feed-generator filtering.
DiscordPlatformData:
type: object
required: [channelId]
properties:
channelId:
type: string
description: Target channel snowflake ID. Determines which channel in the connected server receives the message.
example: "1234567890123456789"
embeds:
type: array
description: Up to 10 Discord embed objects (combined max 6,000 characters across all embeds). Sent alongside or instead of plain-text content.
maxItems: 10
items:
type: object
properties:
title:
type: string
description: Embed title (max 256 chars)
description:
type: string
description: Embed body text (max 4,096 chars)
url:
type: string
description: URL the title links to
color:
type: integer
description: Embed accent color as decimal integer (e.g. 5814783 for blue). Convert hex to decimal.
image:
type: object
properties:
url: { type: string }
thumbnail:
type: object
properties:
url: { type: string }
footer:
type: object
properties:
text: { type: string, description: "Footer text (max 2,048 chars)" }
icon_url: { type: string }
author:
type: object
properties:
name: { type: string, description: Author name (max 256 chars) }
url: { type: string }
icon_url: { type: string }
fields:
type: array
description: Up to 25 fields per embed
maxItems: 25
items:
type: object
required: [name, value]
properties:
name: { type: string, description: Field name (max 256 chars) }
value: { type: string, description: "Field value (max 1,024 chars)" }
inline: { type: boolean, description: Display fields side-by-side }
poll:
type: object
description: Native Discord poll. Cannot be combined with media attachments in the same message.
properties:
question:
type: object
required: [text]
properties:
text: { type: string, description: Poll question (max 300 chars) }
answers:
type: array
description: 1-10 answer options
maxItems: 10
items:
type: object
properties:
poll_media:
type: object
properties:
text: { type: string, description: Answer text }
duration:
type: integer
description: Poll duration in hours (1-768). Default 24.
minimum: 1
maximum: 768
allow_multiselect:
type: boolean
description: Allow users to select multiple answers. Default false.
crosspost:
type: boolean
description: Auto-crosspost to every server following this announcement channel (type 5). No-op for regular text channels.
forumThreadName:
type: string
description: Thread title for forum channel posts (type 15). Required when posting to a forum channel.
forumAppliedTags:
type: array
description: Tag snowflake IDs to apply to forum posts. Max 5 tags.
maxItems: 5
items:
type: string
threadFromMessage:
type: object
description: Create a follow-up thread under the published message.
properties:
name:
type: string
description: Thread name (1-100 chars)
autoArchiveDuration:
type: integer
description: Auto-archive after inactivity (minutes)
enum: [60, 1440, 4320, 10080]
rateLimitPerUser:
type: integer
description: Slow-mode duration in seconds (0-21600)
minimum: 0
maximum: 21600
tts:
type: boolean
description: Send as text-to-speech message. Discord reads the message aloud in the channel.
webhookUsername:
type: string
description: Override the webhook display name for this post only (1-80 chars). Falls back to the account-level default set via PATCH /v1/connect/discord.
webhookAvatarUrl:
type: string
description: Override the webhook avatar URL for this post only. Falls back to the account-level default.
description: |
Discord message settings. Supports plain text (2,000 chars), rich embeds (up to 10), native polls, forum posts, threads, and announcement crossposts. Media attachments support images (JPEG, PNG, GIF, WebP), videos (MP4), and documents (up to 10 files, 25 MB each). Webhook identity (username + avatar) can be customized per-account via PATCH /v1/connect/discord or per-post via webhookUsername/webhookAvatarUrl.
SlackPlatformData:
type: object
properties:
threadTs:
type: string
description: 'Parent message ts to post this message as a thread reply (e.g. "1503435956.000247").'
unfurlLinks:
type: boolean
description: 'Expand links in the message into preview cards. Default true.'
unfurlMedia:
type: boolean
description: 'Expand media links into inline previews. Default true.'
username:
type: string
description: 'Override the bot display name for this message only (requires no setup; shown with an APP badge). Does not change the app identity in the sidebar.'
iconUrl:
type: string
description: 'Override the bot avatar image URL for this message only.'
description: |
Slack message settings. Posts mrkdwn text (up to 40,000 chars; Slack truncates beyond that) to the channel fixed by the connected account, with up to 10 media files per post uploaded via Slack's file API (the text becomes the caption). The target channel is chosen at connect time (one connected account per channel), so channelId is NOT accepted here (a 400 is returned); connect the desired channel via /v1/connect/slack and target its accountId. Messages over 4,000 characters cannot be edited later (Slack's edit limit is stricter than its post limit).
DiscordScheduledEvent:
type: object
description: |
Discord guild scheduled event. Returned by /v1/discord/guilds/{guildId}/events endpoints.
Fields below are the subset Zernio consumes. Discord may return more (e.g. creator,
image hash) which we pass through verbatim.
properties:
id: { type: string, description: Event snowflake ID }
guild_id: { type: string }
channel_id: { type: [string, "null"], description: Voice/stage channel ID; null for external events. }
creator_id: { type: [string, "null"] }
name: { type: string }
description: { type: [string, "null"] }
scheduled_start_time: { type: string, format: date-time }
scheduled_end_time: { type: [string, "null"], format: date-time, description: Required for external events; optional for voice/stage. }
privacy_level: { type: integer, enum: [2], description: "Always 2 (GUILD_ONLY). Discord deprecated PUBLIC events." }
status:
type: integer
enum: [1, 2, 3, 4]
description: "1=SCHEDULED, 2=ACTIVE, 3=COMPLETED, 4=CANCELED"
entity_type:
type: integer
enum: [1, 2, 3]
description: "1=STAGE_INSTANCE, 2=VOICE, 3=EXTERNAL"
entity_id: { type: [string, "null"] }
entity_metadata:
type: [object, "null"]
properties:
location: { type: string, description: External event location string. }
user_count: { type: integer, description: Number of members who RSVP'd. Only present when withUserCount=true on list. }
image: { type: [string, "null"], description: Cover image hash; build URL via cdn.discordapp.com. }
QueueSlot:
type: object
properties:
dayOfWeek:
type: integer
description: Day of week (0=Sunday, 6=Saturday)
minimum: 0
maximum: 6
time:
type: string
description: Time in HH:mm format (24-hour)
pattern: '^([0-1][0-9]|2[0-3]):[0-5][0-9]$'
QueueSchedule:
type: object
properties:
_id:
type: string
description: Unique queue identifier
profileId:
type: string
description: Profile ID this queue belongs to
name:
type: string
description: Queue name (e.g., "Morning Posts", "Evening Content")
timezone:
type: string
description: IANA timezone (e.g., America/New_York)
slots:
type: array
items:
$ref: '#/components/schemas/QueueSlot'
active:
type: boolean
description: Whether the queue is active
isDefault:
type: boolean
description: Whether this is the default queue for the profile (used when no queueId specified)
createdAt:
type: string
format: date-time
updatedAt:
type: string
format: date-time
Pagination:
type: object
properties:
page: { type: integer }
limit: { type: integer }
total: { type: integer }
pages: { type: integer }
Profile:
type: object
properties:
_id: { type: string }
userId: { type: string }
name: { type: string }
description: { type: string }
color: { type: string }
isDefault: { type: boolean }
isOverLimit:
type: boolean
description: Only present when includeOverLimit=true. Indicates if this profile exceeds the plan limit.
createdAt: { type: string, format: date-time }
SocialAccount:
type: object
required: [_id, platform, profileId, isActive]
properties:
_id: { type: string }
platform:
type: string
enum: [tiktok, instagram, facebook, youtube, linkedin, twitter, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, discord, slack, whatsapp, linkedinads, metaads, pinterestads, tiktokads, xads, googleads, openaiads, 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.
While true, GET /v1/connect/{platform}/ads returns a
fresh authUrl (implicit force=true) instead of alreadyConnected,
so re-running the connect flow recovers the account. Cleared
automatically when the account is re-authorized.
followersCount:
type: number
description: Follower count (only included if user has analytics add-on)
followersLastUpdated:
type: string
format: date-time
description: Last time follower count was updated (only included if user has analytics add-on)
parentAccountId:
type: [string, "null"]
description: |
Reference to the parent posting SocialAccount. Set for ads accounts that share
or derive from a posting account's OAuth token. null for standalone ads (Google Ads)
and all posting accounts. Meta ads business-login accounts also have no parent.
enabled:
type: boolean
description: |
Whether the user explicitly activated this account. false means the account was
created as a side effect (e.g., posting account auto-created when user connected
ads first). Such accounts are hidden from this list, cannot be posted to
(`ACCOUNT_NOT_ENABLED_FOR_POSTING`), and are not billed as connected accounts.
metadata:
type: object
description: |
Platform-specific metadata. Fields vary by platform. For WhatsApp accounts, includes:
- qualityRating: Phone number quality rating from Meta (GREEN, YELLOW, RED, or UNKNOWN)
- nameStatus: Display name review status (APPROVED, PENDING_REVIEW, DECLINED, or NONE). A declined or pending display name does not by itself block sending; sendability is reported separately via health_status (can_send_message).
- messagingLimitTier: Maximum unique business-initiated conversations per 24h rolling window (TIER_250, TIER_1K, TIER_10K, TIER_100K, or TIER_UNLIMITED). Scales automatically as quality rating improves.
- verifiedName: Meta-verified business display name
- displayPhoneNumber: Formatted phone number (e.g., "+1 555-123-4567")
- wabaId: WhatsApp Business Account ID
- phoneNumberId: Meta phone number ID
For Meta ads business-login accounts:
- tokenType: system-user
- businessId: The owning Business Manager ID when there is one owner; null for multiple owners.
- businessIds: Owning Business Manager IDs discovered from granted ad accounts.
- grantedAdAccountIds: Ad-account IDs granted to the token.
- adAccountBusinesses: Map from ad-account ID to its owning business ID or null.
- availablePages: Granted Page IDs and names. No Page tokens are exposed.
- selectedPageId: The Page selected for creatives and lead forms, or null.
- scopedAdAccountIds: Existing sync scope preserved on reconnect.
Non-expiring tokens have no tokenExpiresAt field. Parent posting reconnects do not replace this token.
For LinkedIn accounts, profileData carries the profile details refreshed on each daily snapshot:
- profileData.bio: The member's headline for personal accounts, or the organization description for organization accounts. null when the member has not set one.
- profileData.extraData.vanityName: The member's profile slug, i.e. the /in/{vanityName} segment of profileUrl. Personal accounts only; an organization's own slug is in metadata.organizationInfo.vanityName.
AccountWithFollowerStats:
allOf:
- $ref: '#/components/schemas/SocialAccount'
- type: object
properties:
currentFollowers: { type: number, description: Current follower count }
lastUpdated: { type: string, format: date-time }
growth: { type: number, description: Follower change over period }
growthPercentage: { type: number, description: Percentage growth }
dataPoints: { type: number, description: Number of historical snapshots }
accountStats:
type: object
description: |
Platform-specific account stats from the latest daily snapshot.
Fields vary by platform. Only present if metadata has been captured.
properties:
followingCount: { type: number, description: Number of accounts being followed }
mediaCount: { type: number, description: Total media posts (Instagram) }
videoCount: { type: number, description: "Total videos (YouTube, TikTok)" }
tweetCount: { type: number, description: Total tweets (X) }
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
holding at least one live token.
properties:
clientId: { type: string }
clientName:
type: string
description: 'Name the client declared at registration. Registration is open, so this is self-declared and not verified.'
redirectHost:
type: [string, "null"]
description: 'Host of the client''s registered redirect URI (non-http schemes are shown as scheme//host). The destination an impostor cannot fake.'
scopes:
type: array
items: { type: string }
description: Scopes granted on the most recent token.
authorizedAt: { type: [string, "null"], format: date-time }
lastUsedAt:
type: [string, "null"]
format: date-time
description: Last time any of the client's live tokens authenticated a request.
tokenCount:
type: integer
description: 'Live tokens held by the client (an active session is typically one access plus one refresh token).'
BillingSnapshot:
type: object
description: |
Account billing state: plan, cycle, balance, spend caps, and payment /
access status. Returned by `GET /v1/billing`.
properties:
billingSystem:
type: string
enum: [metronome, stripe, 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
breakdown (the CHARGE view). Returned by `GET /v1/usage`.
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
Stripe Tax against the billing address (the same engine the real
invoice uses; invoices apply exclusive tax, so the card is charged
total + tax). Null when the account has no billing address on
file, the total is zero or negative, or the estimate failed.
properties:
taxUsd: { type: number, description: "Estimated tax in USD, added on top of `totals.total`." }
ratePercent: { type: [number, "null"], description: "Combined rate percentage, e.g. 21." }
jurisdictionLabel: { type: [string, "null"], description: 'Human jurisdiction label, e.g. "ES VAT" or "WA sales tax".' }
reverseCharge: { type: boolean, description: True for EU/UK B2B reverse charge (0 tax added by design). }
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`:
* Stripe users (default): per-period counters like `usage.uploads` and
`usage.profiles` are returned, scoped by the plan's `limits`.
* Usage-based billing users: `limits` are unlimited (-1). The
`usage` block carries connected-account and per-X-operation counts,
and the `spend` block carries current-period costs plus the X cap.
properties:
billingSystem:
type: string
enum: [stripe, metronome]
description: Which billing system the account is on. Shape of `usage`/`spend` differs.
planName: { type: string }
billingPeriod: { type: string, enum: [monthly, yearly] }
signupDate: { type: string, format: date-time }
billingAnchorDay: { type: integer, description: "Day of month (1-31) when the billing cycle resets" }
hasAccess:
type: boolean
description: True if the account is in good standing. False for past-due/unpaid/paused subscriptions.
customerId:
type: [string, "null"]
description: Stripe customer ID, when present.
isInvitedUser:
type: boolean
description: True if this is a team member; limits/usage reflect the account owner.
autoUpgradeEnabled:
type: boolean
description: Stripe-only. Always false for 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`:
Stripe returns `uploads` / `profiles` / `lastReset`;
usage-based billing returns `connectedAccounts` / `xApiCalls` / `xApiCallsByOperation`.
properties:
# Stripe fields
uploads: { type: integer, description: "Stripe users only. Uploads consumed in the current period." }
profiles: { type: integer, description: "Stripe users only. Profiles currently owned." }
lastReset: { type: string, format: date-time, description: "Stripe users only." }
# Usage-based billing fields
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
three historical prices ($0.005/$0.010/$0.015), notably the
$0.200 "Posts with URL" tier added April 2026, are silently
excluded from this shape. Use `xApiCallsByOperation` instead;
it captures every tier and is the source of truth for
per-operation call counts.
properties:
x_api_005: { type: integer, description: "Calls at $0.005 per call (reads, lists, bookmarks, content manage, etc.)" }
x_api_010: { type: integer, description: "Calls at $0.010 per call (user reads, DM reads, follow reads, trends, list create, privacy update)" }
x_api_015: { type: integer, description: "Calls at $0.015 per call (posts/replies, DM sends, user interactions)" }
xApiCallsByOperation:
type: object
additionalProperties:
type: integer
description: |
Usage-based billing only. Per-operation X API call counts keyed by
operation (e.g. `posts_read`, `content_create`,
`content_create_with_url`). Resolve each key to price and metadata
via `GET /v1/billing/x-pricing`. This is the canonical source: it
covers every price tier including the $0.200 URL tier that
`xApiCalls` excludes.
example:
posts_read: 42
content_create: 7
content_create_with_url: 3
dm_interaction_create: 1
spend:
type: object
description: "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
`xApiCallsByOperation` × per-operation prices. Tier-agnostic
(covers every price including the $0.200 URL tier). Rounded
up for conservative enforcement against `xSpendLimitCents`.
xSpendLimitCents:
type: [integer, "null"]
description: |
Monthly X spend cap set by the account owner, or null if no cap.
When current X spend hits this cap, analytics and inbox sync are
auto-paused for X accounts. Publishing is never blocked by this cap.
XApiPricing:
type: object
description: |
Canonical X API pricing table. Zernio passes X API costs through
at exact rates with zero markup, so every call you make has a known per-unit
price. Use this payload alongside `/v1/usage-stats` (which returns
per-operation call counts via `xApiCallsByOperation`) to compute exact
cost attribution by X action.
properties:
currency: { type: string, example: USD }
markup: { type: string, example: "0%", description: "Always 0%, 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,
`x_api_200` for $0.200). The first three keys map to the
legacy `xApiCalls` aggregate; new tiers (e.g. `x_api_200`
for the URL tier added April 2026) are surfaced here but
not in the legacy shape.
example: x_api_005
pricePerCallUsd: { type: number, example: 0.005 }
operationCount: { type: integer, example: 13 }
operations:
type: array
description: Flat list of every X operation Zernio can perform, with its rate.
items: { $ref: '#/components/schemas/XApiOperation' }
XApiOperation:
type: object
description: A single X API operation with its per-call price and the Zernio platform methods that trigger it.
properties:
operation:
type: string
example: posts_read
description: Internal operation key. Matches keys in `xApiCallsByOperation`.
eventType:
type: string
example: x_posts_read
description: 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
$0.005, `x_api_200` for $0.200). Useful for grouping operations
by price in dashboards.
example: x_api_005
triggeredBy:
type: array
description: Zernio platform methods that emit this operation, with their metering rule.
items:
type: object
properties:
method:
type: string
example: getPostAnalytics
description: Zernio platform method name.
metering:
type: string
enum: [always, analytics_optin, inbox_optin, absorbed]
description: |
When the method actually bills the user:
* `always`: every call is metered
* `analytics_optin`: only when the X account has analytics enabled
* `inbox_optin`: only when the X account has inbox sync enabled
* `absorbed`: Zernio eats the cost, never billed
PostAnalytics:
type: object
properties:
impressions: { type: integer, example: 0 }
reach: { type: integer, example: 0 }
likes: { type: integer, example: 0 }
comments: { type: integer, example: 0 }
shares: { type: integer, example: 0 }
saves: { type: integer, example: 0, description: 'Number of saves/bookmarks (Instagram, Pinterest, 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
`syncedAt`, not the amount they moved by since the previous snapshot, so a later
entry for the same `postId` always supersedes an earlier one.
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
is NOT the order entries arrive in and it is not a resume point: a slow cycle
writes its rows after a faster cycle that started later, so `syncedAt` can go
backwards between consecutive entries. Use `nextCursor` to resume.
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
the bootstrap call (no `cursor` supplied) and whenever nothing has changed
since your cursor.
items:
$ref: '#/components/schemas/AnalyticsDeltaEntry'
nextCursor:
type: string
description: |
Cursor to send on the next call. ALWAYS present, including on an empty page,
so you always have something to advance with, and it never moves backwards.
Opaque: pass it back verbatim, and do not parse, construct or compare cursors.
example: v1.WyIyMDI2LTA5LTAxIDE3OjEyOjA0IiwiNjVmMWMwYTllMmI1YWYwMDEyYWIzNGNkIl0
hasMore:
type: boolean
description: |
True when more changes are already waiting past `nextCursor`, so call again
immediately. False means you are caught up: keep `nextCursor` and poll again
later. This feed never ends, so `hasMore: false` does NOT mean `nextCursor`
is null.
# LinkedIn Aggregate Analytics Responses
LinkedInAggregateAnalyticsTotalResponse:
type: object
description: Response for TOTAL aggregation (lifetime totals)
properties:
accountId: { type: string }
platform: { type: string, example: linkedin }
accountType: { type: string, example: personal }
username: { type: string }
aggregation: { type: string, enum: [TOTAL] }
dateRange:
type: [object, "null"]
properties:
startDate: { type: string, format: date }
endDate: { type: string, format: date }
analytics:
type: object
properties:
impressions: { type: integer, description: Total impressions across all posts }
reach: { type: integer, description: Unique members reached across all posts }
reactions: { type: integer, description: Total reactions across all posts }
comments: { type: integer, description: Total comments across all posts }
shares: { type: integer, description: Total reshares across all posts }
saves: { type: integer, description: Total times posts were saved (personal accounts only) }
sends: { type: integer, description: Total times posts were sent via LinkedIn messaging (personal accounts only) }
engagementRate: { type: number, description: 'Overall engagement rate, as 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 }
# ============================================
# Response Schemas
# ============================================
# Posts Responses
PostsListResponse:
type: object
properties:
posts:
type: array
items:
$ref: '#/components/schemas/Post'
pagination:
$ref: '#/components/schemas/Pagination'
PostGetResponse:
type: object
properties:
post:
$ref: '#/components/schemas/Post'
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'
# Profiles Responses
ProfilesListResponse:
type: object
properties:
profiles:
type: array
items:
$ref: '#/components/schemas/Profile'
total:
type: integer
description: 'Total matching profiles across all pages. Present only when limit or skip was passed.'
skip:
type: integer
description: 'Offset applied. Present only when limit or skip was passed.'
limit:
type: integer
description: Echo of the limit query param. Present only when it was passed.
ProfileCreateResponse:
type: object
properties:
message:
type: string
profile:
$ref: '#/components/schemas/Profile'
ProfileGetResponse:
type: object
properties:
profile:
$ref: '#/components/schemas/Profile'
ProfileUpdateResponse:
type: object
properties:
message: { type: string }
profile:
$ref: '#/components/schemas/Profile'
ProfileDeleteResponse:
type: object
properties:
message: { type: string }
AccountsListResponse:
type: object
required: [accounts, hasAnalyticsAccess]
properties:
accounts:
type: array
items: { $ref: '#/components/schemas/SocialAccount' }
hasAnalyticsAccess:
type: boolean
description: Whether user has analytics add-on access
pagination:
description: Only present when page/limit params are provided
$ref: '#/components/schemas/Pagination'
FollowerStatsResponse:
type: object
properties:
accounts:
type: array
items:
$ref: '#/components/schemas/AccountWithFollowerStats'
stats:
type: object
additionalProperties:
type: array
items:
type: object
properties:
date: { type: string, format: date }
followers: { type: number }
dateRange:
type: object
properties:
from: { type: string, format: date-time }
to: { type: string, format: date-time }
granularity: { type: string }
UploadedFile:
type: object
properties:
type:
type: string
enum: [image, video, document]
url: { type: string, format: uri }
filename: { type: string }
size: { type: integer }
mimeType: { type: string }
MediaUploadResponse:
type: object
properties:
files:
type: array
items:
$ref: '#/components/schemas/UploadedFile'
UploadTokenResponse:
type: object
properties:
token: { type: string }
uploadUrl: { type: string, format: uri }
expiresAt: { type: string, format: date-time }
status:
type: string
enum: [pending, completed, expired]
UploadTokenStatusResponse:
type: object
properties:
token: { type: string }
status:
type: string
enum: [pending, completed, expired]
files:
type: array
items:
$ref: '#/components/schemas/UploadedFile'
createdAt: { type: string, format: date-time }
expiresAt: { type: string, format: date-time }
completedAt: { type: [string, "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 }
# Users Responses
User:
type: object
properties:
_id:
type: string
email:
type: string
name:
type: string
role:
type: string
createdAt:
type: string
format: date-time
UsersListResponse:
type: object
properties:
users:
type: array
items:
$ref: '#/components/schemas/User'
UserGetResponse:
type: object
properties:
user:
$ref: '#/components/schemas/User'
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.50
purchaseValue:
type: number
description: "Convenience sum of purchase-type action values, picked from `actionValues` via the same priority list as `conversions` so both fields describe the same events. In ad-account native currency. 0 when the campaign has no purchase event configured. Meta-only."
roas:
type: number
description: "Return on ad spend, derived as `purchaseValue / spend`. 0 when `spend` is 0. Equivalent to Meta's `purchase_roas` under default attribution. At ad-set and campaign levels this is recomputed from summed purchaseValue + spend (NOT averaged across children) so it's mathematically correct at every rollup level."
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`
so you never have to parse action-type strings yourself.
Meta reports one event under several action types at once
(`offsite_conversion.fb_pixel_purchase`, `omni_purchase`, `purchase`, …).
Each field below takes the FIRST family member present rather than
summing them, which is what makes these counts safe to add up. Summing
the raw `actions` keys yourself double or triple counts. The same
priority order backs `conversions`, so a purchase-optimised campaign
reports the identical number in `conversions` and `funnel.purchases`.
Every field is 0 when that step never fired. Populated for Meta ads;
other platforms report a different action taxonomy and generally leave
these at 0 (read `actions` for those). At ad-set and campaign level each
step is summed from its per-ad values.
properties:
landingPageViews: { type: integer, description: "Landing page views: the visitor actually loaded the destination, unlike a link click. Meta `landing_page_view`." }
contentViews: { type: integer, description: "Content views (Meta `ViewContent` pixel event)." }
searches: { type: integer, description: "On-site searches (Meta `Search` pixel event)." }
wishlistAdds: { type: integer, description: "Adds to wishlist (Meta `AddToWishlist` pixel event)." }
cartAdds: { type: integer, description: "Adds to cart (Meta `AddToCart` pixel event)." }
checkoutsInitiated: { type: integer, description: "Checkouts started (Meta `InitiateCheckout` pixel event)." }
paymentInfoAdds: { type: integer, description: "Payment details added at checkout (Meta `AddPaymentInfo` pixel event)." }
purchases: { type: integer, description: "Purchases (Meta `Purchase` pixel event). Pair with `purchaseValue` for revenue." }
leads: { type: integer, description: "Leads, from either the website pixel or an instant form, whichever the ad uses." }
registrationsCompleted: { type: integer, description: "Completed registrations (Meta `CompleteRegistration` pixel event)." }
appInstalls: { type: integer, description: "Mobile app installs attributed to the ad." }
messagingConversationsStarted: { type: integer, description: "Messaging conversations started within 7 days, the headline metric for click-to-WhatsApp and click-to-Messenger ads." }
messagingFirstReplies: { type: integer, description: "Messaging threads where the person sent a first reply." }
AdEngagementCounts:
type: object
description: |
The single `engagement` total split into the interactions behind it.
`engagement` is not the sum of these: Meta's own
`post_engagement` and `page_engagement` totals already contain the
individual interactions, and all of them are counted into `engagement`.
Use these fields when you need a specific interaction, and `engagement`
only as the coarse total it has always been.
Populated for Meta and, since 2026-08, TikTok (`reactions` = paid
likes, `comments`, `shares`; TikTok's `follow` count lives in
`actions.follow`, not here). Other platforms leave these at 0.
TikTok history note: paused TikTok ads are not re-synced, so
campaigns that ended before the rollout keep 0s here.
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
apply to. Returned inside a node's `daily[]` when `GET /v1/ads/tree` is
called with `timeIncrement=1`. Rate metrics (ctr/cpc/cpm/costPerConversion/
roas/videoAvgTimeWatchedActions) are recomputed per day from that day's
sums, so summing the additive fields across a node's `daily[]` reproduces
its aggregated `metrics` total. `reach` is the exception: on Meta and
TikTok the aggregated total is de-duplicated across the range, so daily
reach does not sum to it. Do NOT sum or plain-average
`videoAvgTimeWatchedActions` across days: the range value is the
play-weighted average of the daily values.
allOf:
- $ref: '#/components/schemas/AdMetrics'
- type: object
properties:
date: { type: string, format: date, description: "Calendar day (YYYY-MM-DD) these metrics apply to." }
AdStatus:
type: string
enum: [active, paused, pending_review, rejected, completed, cancelled, error]
AdReviewStatus:
type: string
enum: [in_review, approved, rejected, with_issues]
description: "Platform-side review state, independent of the delivery `status` and the `configuredStatus` on/off toggle. `in_review` means the platform is still reviewing. Absent when the platform reports no review signal (e.g. a paused ad whose review state is masked behind the pause)."
BusinessCenter:
type: object
description: |
TikTok Business Center entity. Returned by `GET /v1/ads/business-centers`. BCs are
TikTok's agency container: one BC owns N advertisers (ad accounts). Most solo
advertisers don't have one; the agency token uses BCs to roll up multi-client access.
properties:
bcId:
type: string
description: Business Center ID
example: "7123456789012345678"
name:
type: string
description: Display name set by the BC owner
example: "Acme Agency"
advertiserCount:
type: [integer, "null"]
description: |
Number of advertisers reachable under this BC for the calling token.
`null` when the BC asset walk returned empty or failed (typical for
agency apps without full BC asset read scope), distinct from `0`,
which would imply the BC genuinely has no advertisers.
example: 23
ValueRuleCriterion:
type: object
additionalProperties: false
required: [criteriaType, operator, criteriaValues, criteriaValueTypes]
description: |
One matching condition inside a value rule. `criteriaValues` and `criteriaValueTypes`
are POSITIONALLY paired: same length, same order.
properties:
id:
type: string
description: |
Platform criterion id. Echo it on `PUT` to KEEP this criterion, omit it to CREATE a
new one. A criterion left out of the array entirely is DELETED.
criteriaType:
type: string
enum: [AGE, GENDER, OS_TYPE, DEVICE_PLATFORM, LOCATION, PLACEMENT, OMNI_CHANNEL, AUDIENCE_LABEL]
description: |
The dimension being matched. `OMNI_CHANNEL` (conversion location: APP, INSTANT_FORM,
PHONE_CALL, WEBSITE) is accepted even though Meta's own enum table omits it.
operator:
type: string
enum: [CONTAINS]
description: "Required on every criterion. `CONTAINS` is currently the only value Meta supports."
criteriaValues:
type: array
minItems: 1
items: { type: string }
description: |
The values to match. `AGE` takes ranges such as `18-24`, `18+` or a custom `18-26`;
a range whose upper bound is 65 is NOT allowed (use `18+` instead of `18-65`).
`LOCATION` takes Targeting-Search keys: a two-letter country code for
`LOCATION_COUNTRY`, a numeric key for region / city / comScore market.
`AUDIENCE_LABEL` takes labels such as `HIGH_VALUE`, which are applied to a Custom
Audience in Ads Manager: there is no API to provision them, so they are passed
through unvalidated.
criteriaValueTypes:
type: array
minItems: 1
items: { type: string }
description: |
One entry per `criteriaValues` entry, in the same order. The literal `"NONE"` for
every criteriaType except `LOCATION`, which uses `LOCATION_COUNTRY`,
`LOCATION_REGION`, `LOCATION_CITY` or `LOCATION_COMSCORE_MARKET` and MAY mix them
within one criterion. `LOCATION_DMA` was replaced by `LOCATION_COMSCORE_MARKET` on
2026-06-22 and is rejected by this API.
ValueRule:
type: object
additionalProperties: false
required: [name, adjustSign, adjustValue, criteria]
description: |
One bid-adjustment rule. Rules are evaluated in ARRAY ORDER and only the first matching
rule adjusts the bid for an overlapping audience, so the order is semantic.
properties:
id:
type: string
description: |
Platform rule id. Echo it on `PUT` to KEEP this rule, omit it to CREATE a new one.
A rule left out of the array entirely is DELETED.
name: { type: string, minLength: 1, maxLength: 255 }
adjustSign:
type: string
enum: [INCREASE, DECREASE]
description: "Direction of the adjustment. There is no signed value field."
adjustValue:
type: integer
minimum: 1
maximum: 1000
description: |
Unsigned percentage magnitude. `INCREASE` accepts 1-1000, `DECREASE` accepts 1-90.
0 is out of range on both.
status:
type: string
description: |
Meta returns `ACTIVE` here but documents no enum for the field. Treat it as a
passthrough: echo whatever the `GET` returned, and do not synthesize values.
criteria:
type: array
minItems: 1
maxItems: 4
items: { $ref: '#/components/schemas/ValueRuleCriterion' }
description: "All criteria on a rule must match for the rule to fire."
ValueRuleSet:
type: object
additionalProperties: false
required: [id, name, rules]
description: |
A named set of bid-adjustment rules on an ad account. Attach it to an ad set with
`valueRuleSetId`. Limits: 6 sets per ad account, 10 rules per set, 4 criteria per rule.
properties:
id: { type: string, description: "Platform value rule set id." }
name: { type: string }
rules:
type: array
items: { $ref: '#/components/schemas/ValueRule' }
description: "Evaluated in order; the first matching rule wins."
BidStrategy:
type: string
enum: [LOWEST_COST_WITHOUT_CAP, LOWEST_COST_WITH_BID_CAP, COST_CAP, LOWEST_COST_WITH_MIN_ROAS]
description: |
Meta bid strategy. Same enum applies at campaign and ad-set level; ad-set value (when set)
overrides campaign-level. Cross-field rules:
- `LOWEST_COST_WITHOUT_CAP` (default): auto-bid, forbids `bidAmount` and `roasAverageFloor`.
- `LOWEST_COST_WITH_BID_CAP` / `COST_CAP`: require `bidAmount` (whole currency units).
- `LOWEST_COST_WITH_MIN_ROAS`: requires `roasAverageFloor` (decimal multiplier, 2.0 = 2.0x).
Source: facebook-business-sdk-codegen api_specs/specs/enum_types.json (`AdSet_bid_strategy`,
`Campaign_bid_strategy`).
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.
**Meta**: forwarded to the ad set's `promoted_object` (snake-cased).
For `goal: app_promotion`, it is also sent on the campaign only when
`isSkadnetworkAttribution: true`. Plain Android app installs keep the
existing campaign payload, with the promoted object only on the ad set.
POST /v1/ads/campaigns forwards this object only for that explicit SKAN flag.
Required for goals whose ad-set optimization_goal points at a specific
event/page/app (without it Meta rejects the ad-set create with
`error_subcode: 1815430` "Please select a promoted object for your ad set"):
- `goal: conversions` / `lead_conversion` (OFFSITE_CONVERSIONS): requires `pixelId` + `customEventType`, or `customConversionId` when optimising against a Custom Conversion (the conversion carries its own event definition). For a pixel CUSTOM event (one you named yourself in CAPI/Events Manager), send `customEventType: OTHER` + `customEventStr` with the event name.
- `goal: app_promotion` (APP_INSTALLS): requires `applicationId` + `objectStoreUrl`
- `goal: lead_generation` (LEAD_GENERATION): `pageId` is auto-filled from the connected Page when omitted
Other Meta goals (engagement, traffic, awareness, video_views) ignore this field.
**TikTok**: used by `goal: conversions` and the Smart+ goals (`smartPlus: true`).
- `pixelId` maps to the ad group's `pixel_id`. Required: a TikTok website-conversion
ad group without a pixel is rejected with `40002: Please select a pixel`.
- `customEventType` maps to the ad group's `optimization_event` (the pixel event to
optimise for). Optional on the regular conversions flow, required on Smart+.
See the `customEventType` field below for the valid TikTok codes.
- `applicationId` (Smart+ `goal: app_promotion` only) maps to the ad group's `app_id`:
the App ID of an app registered on the TikTok Ads account (Assets → Events →
App Events). Install optimization needs the app's MMP tracking configured.
The remaining `promotedObject.*` fields are Meta-only. Platforms other than
Meta and TikTok ignore `promotedObject` entirely.
properties:
pixelId:
type: string
description: |
Pixel ID. **Meta:** Facebook Pixel ID, required for `goal: conversions`.
Requires `customEventType` alongside it; Meta rejects any promoted_object
carrying `pixel_id` without `custom_event_type` (error_subcode 1885014),
even when `customConversionId` is also present.
**TikTok:** TikTok Pixel ID, required for `goal: conversions`.
To discover the pixels an ad account can use, call
`GET /v1/accounts/{accountId}/tracking-tags?adAccountId=act_...` (each entry
carries `kind` and `ownerAdAccountId`), or
`GET /v1/accounts/{accountId}/conversion-destinations`. Note this is a
different resource from `GET /v1/ads/{adId}/tracking-tags`, which reads an
ad's click-URL params (`url_tags`), not pixels.
customEventType:
type: string
description: |
The event the campaign/ad group optimises against.
**Meta:** standard event like `PURCHASE`, `LEAD`, `COMPLETE_REGISTRATION`,
`ADD_TO_CART`. Uppercased internally so callers can pass any case. Required
for `goal: conversions`.
**TikTok:** an `optimization_event` code (UPPER_SNAKE, not Meta's vocabulary
and not PascalCase), OR the exact event name shown in TikTok Events Manager
(auto-resolved to its code). Must be one of the event types your TikTok
Pixel tracks; custom events are not optimizable. Current taxonomy:
`SHOPPING` (Purchase), `ON_WEB_CART` (Add to Cart), `INITIATE_ORDER`
(Initiate Checkout), `FORM` (Lead), `ON_WEB_REGISTER` (Complete
Registration), `ON_WEB_DETAIL` (View Content). `ON_WEB_ORDER` is
deprecated. On rejection the error lists the event types your pixel
actually tracks. Optional for `goal: conversions`.
customEventStr:
type: string
description: |
Meta only. Pixel custom-event name to optimise against (Meta's
`custom_event_str`), exactly as it appears in Events Manager and in your
CAPI payloads (case-sensitive, not uppercased). Requires
`customEventType: OTHER`, and `OTHER` requires this field (400 either way).
The same as picking a custom event in Ads Manager's conversion-event
dropdown. For rule-based Custom Conversions use `customConversionId`
instead.
pageId:
type: string
description: |
Facebook Page ID. Used by `goal: lead_generation`. Auto-filled from the
connected Page when omitted.
applicationId:
type: string
description: "App ID. Required for `goal: app_promotion`."
objectStoreUrl:
type: string
format: uri
description: "App Store / Play Store listing URL. Required for `goal: app_promotion`."
customConversionId:
type: string
description: |
Custom Conversion ID, when optimising against one instead of a standard
event. Accepted alone by this API, without `pixelId` or `customEventType`.
If `pixelId` is also sent, `customEventType` is still required on the
promoted_object (Meta rejects `pixel_id` without `custom_event_type`,
error_subcode 1885014).
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
empty object targets the platform's default broadest audience. Field names are
camelCase and identical across `POST /v1/ads/create` (the `targeting` object),
`POST /v1/ads/targeting/reach-estimate`, and `saved_targeting` audiences, so a
spec resolved once can be reused verbatim.
Entity ids (`regions[].key`, `cities[].key`, `zips[].key`, `metros[].key`,
`interests[].id`, `behaviors[].id`) are the platform's opaque identifiers
resolved via `GET /v1/ads/targeting/search`. A spec is therefore meaningful only
for the platform it was built against, except the portable fields (`countries`,
`ageMin`/`ageMax`, `gender`, `incomeTier`, `languages`) which carry across
platforms. Fields a platform cannot honour are rejected at create time with
`INVALID_FIELD_VALUE` naming the offending field (not silently dropped).
properties:
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`),
for targeting a whole region without listing its countries. Combines
with `countries` rather than replacing it, and is also accepted under
`excludedLocations`. Discoverable via
`GET /v1/ads/targeting/search?dimension=geo&geoType=country_group`.
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
express all four. Google maps only `top_10` (its INCOME_RANGE_90_UP); other
tiers on Google, and any income tier on LinkedIn / X / Pinterest, are rejected.
On Meta, income/zip targeting requires the relevant `specialAdCategories` to be
unset (housing/employment/credit ads cannot use it).
languages: { type: array, items: { type: string }, description: "Language codes restricting the audience by language. On Meta, ISO 639-1 codes (e.g. ['en']); a bare code targets all regional variants (\"en\" = all English), or use a region-qualified code (\"en_GB\", \"pt_BR\") for a specific one. Unknown codes are rejected." }
interests:
type: array
description: "Interest entities from /v1/ads/targeting/search?dimension=interest. Each carries the platform's opaque id."
items:
type: object
required: [id]
properties:
id: { type: string }
name: { type: string }
behaviors:
type: array
description: "Behaviour entities from /v1/ads/targeting/search?dimension=behavior. Supported on Meta and TikTok."
items:
type: object
required: [id]
properties:
id: { type: string }
name: { type: string }
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}.
Google assets are immutable (AssetService only creates), so each field you send becomes new
assets linked to the asset group, and the assets that role held are unlinked in the same
atomic request. Send one field or many; a field you omit is left untouched. Re-sending a
value the asset group already carries is a no-op for that asset, not a re-upload.
Unlinked assets stay in the account's asset library: Google has no asset delete.
At least one description must be 60 characters or fewer. Texts within each list must be distinct.
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`:
- Meta: ad set `optimization_goal` (e.g. OFFSITE_CONVERSIONS, VALUE, LEAD_GENERATION, LINK_CLICKS).
- LinkedIn: the campaign's EFFECTIVE `optimizationTargetType`, refreshed from LinkedIn on every
sync rather than echoing what was passed on create. `NONE` means manual bidding, and it is a
real value, not missing data. Auto-bid values are MAX_IMPRESSION / MAX_CLICK / MAX_CONVERSION /
MAX_VIDEO_VIEW / MAX_LEAD / MAX_REACH; target-cost values are TARGET_COST_PER_CLICK /
TARGET_COST_PER_IMPRESSION / TARGET_COST_PER_VIDEO_VIEW; cost-cap values are the
CAP_COST_AND_MAXIMIZE_* family.
example: OFFSITE_CONVERSIONS
costType:
type: [string, "null"]
description: |
LinkedIn only. The campaign's EFFECTIVE cost model (billing event) as applied by LinkedIn,
refreshed on every sync rather than echoing what was passed on create. One of `CPM` (cost per
thousand impressions), `CPC` (cost per click) or `CPV` (cost per video view). On LinkedIn this is
the axis that pairs with `bidAmount`; there is no `bidStrategy`. For campaign type
SPONSORED_INMAILS, `CPM` bills as cost-per-send x 1000. `null` for non-LinkedIn ads.
example: CPC
servingStatuses:
type: array
items: { type: string }
description: |
LinkedIn only. Why the parent campaign is (or is not) delivering, verbatim from LinkedIn.
A campaign can report `status: ACTIVE` and still serve nothing; this array is what says so.
- `[]` means no serving data: a non-LinkedIn ad, or a LinkedIn ad not yet re-synced.
- `["RUNNABLE"]` means the campaign is eligible to serve.
- Anything else is a hold. Known values include ACCOUNT_SERVING_HOLD, ACCOUNT_TOTAL_BUDGET_HOLD,
ACCOUNT_END_DATE_HOLD, CAMPAIGN_START_DATE_HOLD, CAMPAIGN_END_DATE_HOLD,
CAMPAIGN_TOTAL_BUDGET_HOLD, CAMPAIGN_AUDIENCE_COUNT_HOLD, CAMPAIGN_GROUP_START_DATE_HOLD,
CAMPAIGN_GROUP_END_DATE_HOLD, CAMPAIGN_GROUP_TOTAL_BUDGET_HOLD, CAMPAIGN_GROUP_STATUS_HOLD and
STOPPED. The list is open on purpose, so treat unrecognized values as holds rather than errors.
The end-date and total-budget holds are terminal and surface as `status: completed`; the rest
surface as `status: paused`. A hold is not the only cause of zero delivery: with
manual, target-cost or cost-cap bidding, a `bidAmount` of 0 stops delivery while
`servingStatuses` still reads `["RUNNABLE"]`. Check `costType` / `bidAmount` /
`optimizationGoal` as well.
example: ["ACCOUNT_TOTAL_BUDGET_HOLD"]
platformAdAccountName:
type: [string, "null"]
description: |
Human-readable advertiser/account name (Meta `AdAccount.name`, TikTok
`advertiser_name`, LinkedIn / X / Pinterest equivalents). Refreshed every
sync so platform-side renames propagate within one cycle. `null` when the
platform doesn't return a name or the sync hasn't run yet.
example: "Zernio - previously Late"
platformCreatedAt:
type: [string, "null"]
format: date-time
description: |
Platform-reported creation timestamp (Meta `created_time`, TikTok `create_time`).
Distinct from `createdAt` which reflects when Zernio first synced the doc. To
sort or filter by "when the ad was actually created on the platform", read this field.
`null` for legacy ads synced before this field was added; aggregations fall back
to `createdAt` in that case.
bidStrategy:
anyOf:
- $ref: '#/components/schemas/BidStrategy'
- type: "null"
description: |
Ad-set bid strategy (overrides campaign level on Meta). Populated for Meta and
TikTok. TikTok's native `bid_type` is normalized to the cross-platform Meta enum:
`BID_TYPE_NO_BID` -> `LOWEST_COST_WITHOUT_CAP`, `BID_TYPE_CUSTOM` ->
`LOWEST_COST_WITH_BID_CAP`, deep_bid_type=MIN_ROAS or roas_bid>0 ->
`LOWEST_COST_WITH_MIN_ROAS`, `BID_TYPE_MAX_CONVERSION` -> `LOWEST_COST_WITHOUT_CAP`.
example: LOWEST_COST_WITHOUT_CAP
bidAmount:
type: [number, "null"]
description: |
Bid amount in WHOLE currency units of the ad account (USD: 5 = $5.00; JPY: 100 = ¥100).
- Meta source: `bid_amount` on the ad set (smallest-denomination int, decoded here). Populated
when bidStrategy is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`; `null` for auto-bid
(`LOWEST_COST_WITHOUT_CAP`).
- TikTok source: priority order `bid_price` -> `conversion_bid_price` -> `deep_cpa_bid`
(whichever is set on the ad group). TikTok stores all three in whole currency units.
- LinkedIn source: the campaign's EFFECTIVE `unitCost`, refreshed on every sync rather than
echoing what was passed on create. Its meaning depends on the bidding mode implied by
`optimizationGoal`: bid amount (manual), target cost, or cost cap. It pairs with `costType`,
NOT with `bidStrategy`, which LinkedIn does not have. A value of `0` is a real, delivery-
stopping configuration and not "unset", so do not gate this field on `bidStrategy` for
LinkedIn ads.
Source: facebook-business-sdk-codegen api_specs/specs/AdSet.json (`bid_amount`).
example: 5
roasAverageFloor:
type: [number, "null"]
description: |
Minimum ROAS as a decimal multiplier (2.0 = 2.0x ROAS). Populated when bidStrategy
is `LOWEST_COST_WITH_MIN_ROAS`.
- Meta source: decoded from `bid_constraints.roas_average_floor` (Meta stores as
fixed-point int × 10000; we return the decimal).
- TikTok source: `roas_bid` on the ad group (already a decimal).
Source: facebook-business-sdk-codegen api_specs/specs/AdCampaignBidConstraint.json.
example: 2.0
promotedObject:
type: [object, "null"]
description: "Meta promoted object containing conversion event details. Structure varies by objective. Only present for Meta ads."
properties:
custom_event_type: { type: string, description: "Conversion event type (e.g. PURCHASE, LEAD, COMPLETE_REGISTRATION, ADD_TO_CART)", example: PURCHASE }
pixel_id: { type: string, description: Meta pixel ID }
page_id: { type: string, description: Facebook page ID }
application_id: { type: string, description: Facebook app ID }
product_set_id: { type: string, description: Product catalog set ID }
creative:
type: [object, "null"]
description: Platform-specific creative data. Fields vary by platform.
properties:
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.
A superset of the ad-level `servingStatuses`: it repeats the inherited campaign, campaign
group and account holds AND adds creative-only causes such as UNDER_REVIEW, REJECTED,
PROCESSING, PROCESSING_FAILED, FORM_HOLD (lead-gen-form creatives),
REFERRED_CONTENT_QUALITY_HOLD, JOB_POSTING_ON_HOLD and JOB_POSTING_INVALID (job ads).
Some values are format-specific and will never appear on other ad formats. The list is
open, so treat unrecognized values as holds rather than errors.
example: ["UNDER_REVIEW"]
body: { type: string, description: Ad copy/text }
googleHeadline: { type: string, description: Google Ads headline }
googleDescription: { type: string, description: Google Ads description }
linkUrl: { type: string, description: Destination URL }
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).
For ads created through Zernio this is the spec you supplied. For external ads (synced from
Meta Ads Manager, `isExternal: true`) targeting lives at the ad set and isn't stored at ingest,
so on the first `GET /v1/ads/{adId}` Zernio resolves it live from Meta and caches it on the ad;
the value is then Meta's raw `targeting` shape (snake_case, e.g. `geo_locations`, `age_min`),
the same object Ads Manager shows. May be absent if the ad set exposes no targeting or the lookup fails.
schedule:
type: [object, "null"]
properties:
startDate: { type: string, format: date-time }
endDate: { type: string, format: date-time }
rejectionReason: { type: string }
createdAt: { type: string, format: date-time }
updatedAt: { type: string, format: date-time }
AdTreeAdSet:
type: object
description: Ad set (or ad group/line item depending on platform) with rolled-up metrics and child ads
properties:
platformAdSetId: { type: string }
adSetName: { type: string }
status: { allOf: [{ $ref: '#/components/schemas/AdStatus' }], description: Derived from child ad statuses }
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
children-derived delivery `status`: a campaign can have ads
already active (status=active) while the campaign itself is
still being reviewed by the platform (reviewStatus=in_review).
For Meta, derived from `effective_status` + `issues_info` on
the Campaign, plus ad-level PENDING_REVIEW rollup.
platformCampaignStatus:
type: [string, "null"]
description: "Raw platform-level campaign status (Meta `effective_status`: ACTIVE, PAUSED, DELETED, ARCHIVED, IN_PROCESS, WITH_ISSUES). Distinct from per-ad `platformStatus`."
campaignIssuesInfo:
type: [array, "null"]
description: "Platform-reported campaign issues (Meta `issues_info[]`). Populated only when the platform has delivery issues to report; contains the specific error codes and messages."
items:
type: object
adCount: { type: integer, description: Total ads across all ad sets }
adSetCount: { type: integer }
budget:
type: [object, "null"]
description: Effective budget (back-compat). For CBO this mirrors `campaignBudget`, for ABO this mirrors the child ad-set budget. Use `budgetLevel` to disambiguate.
properties:
amount: { type: number }
type: { type: string, enum: [daily, lifetime] }
campaignBudget:
type: [object, "null"]
description: Campaign-level budget (Campaign Budget Optimization / CBO). Populated only when the platform set the budget at the campaign level. For ABO campaigns this is null and the budget lives on the child ad set.
properties:
amount: { type: number }
type: { type: string, enum: [daily, lifetime] }
budgetLevel:
type: [string, "null"]
enum: [campaign, adset]
description: "Canonical CBO/ABO indicator. `campaign` = CBO (Advantage Campaign Budget, budget lives on the campaign). `adset` = ABO (budget lives on each ad set). Route budget updates to the matching Meta entity."
isBudgetScheduleEnabled:
type: boolean
default: false
description: "Meta-only. Mirrors Campaign.is_budget_schedule_enabled: true when the campaign uses budget scheduling (time-based budget changes). Independent of CBO/ABO."
currency:
type: [string, "null"]
description: "ISO 4217 currency code (e.g. USD, EUR, CLP, JPY) for all budget amounts in this campaign node. Budgets are NOT normalized to USD."
metrics: { $ref: '#/components/schemas/AdMetrics' }
platformAdAccountId: { type: string }
platformAdAccountName: { type: [string, "null"], description: "Human-readable advertiser/account name from the platform. Refreshed on every sync." }
accountId: { type: string }
profileId: { type: string }
advertisingChannelType:
type: [string, "null"]
description: "Google-only. Raw campaign.advertising_channel_type (SEARCH, PERFORMANCE_MAX, 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, not type: [string, array]: hey-api resolves a type-array carrying items
# through the array branch and drops the string one (@zernio/node 0.2.640).
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, not type: [string, array]: hey-api resolves a type-array carrying items
# through the array branch and drops the string one (@zernio/node 0.2.640).
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
(`ad_group_criterion.quality_info`). Every field is null until Google has rated the
keyword: a keyword with too little traffic is unrated, and negatives are never rated.
Google's own UNKNOWN / UNSPECIFIED buckets are reported as null so "unrated" has a
single representation.
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
(email, phone, names) are hashed with SHA-256 server-side using each
platform's normalization rules before they leave Zernio. Callers send
plaintext.
required: [eventName, eventTime, eventId, user]
properties:
eventName:
type: string
description: |
Standard event name (Purchase, Lead, CompleteRegistration, AddToCart,
InitiateCheckout, AddPaymentInfo, Subscribe, StartTrial, ViewContent,
Search, Contact, SubmitApplication, Schedule) or a custom string
(only supported on platforms that accept custom events: Meta and
OpenAI Ads).
Per-platform behavior:
- Meta: free-form; standard names match Meta's built-ins.
- Google: ignored. The conversion action's category determines the type.
- LinkedIn: ignored. The conversion rule's `type` is locked to the destination.
- OpenAI Ads: a fixed subset of standard names (Purchase, Lead, AddToCart, ViewContent, InitiateCheckout, CompleteRegistration, Subscribe, StartTrial, Schedule) maps 1:1 onto OpenAI's own event-type enum; anything else is sent as a custom event with the name preserved.
example: Purchase
eventTime:
type: integer
description: When the conversion happened, in unix seconds.
example: 1744732800
eventId:
type: string
description: |
Unique dedup key. The same eventId must be used on pixel + CAPI
to prevent double-counting. Mapped to event_id on Meta,
transactionId on Google, eventId on LinkedIn (LinkedIn deduplicates
against Insight Tag events with the same eventId; the Insight Tag
event wins when both arrive).
example: order_abc_123
value:
type: number
description: Conversion value in the specified currency.
example: 99.5
currency:
type: string
description: ISO 4217 currency code.
example: USD
user:
type: object
description: User identity fields. More signals mean higher match rates.
properties:
email: { type: string, description: Plaintext email. Hashed server-side. }
phone: { type: string, description: "Phone number, ideally E.164. Hashed server-side." }
firstName: { type: string, description: Plaintext first name. Hashed server-side. }
lastName: { type: string, description: Plaintext last name. Hashed server-side. }
externalId:
type: string
description: |
Stable customer identifier (e.g. CRM user ID). Hashed
server-side for Meta and Google. Sent as plaintext to LinkedIn
(LinkedIn's Conversions API spec requires the raw value).
Maximum effective list size on LinkedIn is 1.
ipAddress: { type: string, description: Client IP address. Sent plaintext. }
userAgent: { type: string, description: Client user-agent string. Sent plaintext. }
country: { type: string, description: "ISO 3166-1 alpha-2 country code, e.g. 'us'." }
city: { type: string, description: "Meta advanced matching (ct). Plaintext city; normalized + SHA-256 hashed server-side. Meta only." }
state: { type: string, description: "Meta advanced matching (st). 2-letter ANSI for US; hashed server-side. Meta only." }
zip: { type: string, description: "Meta advanced matching (zp). US uses first 5 digits; hashed server-side. Meta only." }
dob: { type: string, description: "Meta advanced matching (db). YYYYMMDD; hashed server-side. Meta only." }
gender: { type: string, description: "Meta advanced matching (ge). 'f' or 'm'; hashed server-side. Meta only." }
leadId:
type: string
description: |
Meta lead ID from a Lead Ad submission, as a string. Required
for Conversion Leads CRM events: send it with
`actionSource: 'crm'` and
`platformData: { event_source: 'crm', lead_event_source: '<CRM name>' }`.
Forwarded unhashed to Meta's `user_data.lead_id`. Meta only.
clickIds:
type: object
description: Platform click identifiers captured from the originating ad click.
properties:
fbc: { type: string, description: Meta click ID (from fbclid URL param). }
fbp: { type: string, description: Meta browser ID (_fbp cookie). }
gclid: { type: string, description: Google click ID (from gclid URL param). }
gbraid: { type: string, description: Google iOS 14.5+ app attribution ID. }
wbraid: { type: string, description: Google iOS 14.5+ web-to-app attribution ID. }
li_fat_id:
type: string
description: |
LinkedIn first-party ad tracking click ID. Captured by
parsing `li_fat_id` from landing-page URLs after the
advertiser enables enhanced conversion tracking on the
LinkedIn Insight Tag. Sent to LinkedIn as the
LINKEDIN_FIRST_PARTY_ADS_TRACKING_UUID userId. Opaque
token, not hashed.
items:
type: array
description: Item-level detail for ecommerce events.
items:
type: object
properties:
id: { type: string }
name: { type: string }
price: { type: number }
quantity: { type: integer }
category: { type: string }
sourceUrl:
type: string
format: uri
description: URL where the conversion originated (used by Meta).
actionSource:
type: string
enum: [web, app, offline, crm, phone_call, system_generated]
description: 'Where the conversion happened. Used by Meta. Google also requires an event source internally; omitting this field sends OTHER to Google. Send an explicit value for accurate origin reporting.'
platformData:
type: object
additionalProperties: true
description: |
Escape hatch for platform-specific fields we haven't normalized.
On Meta, keys are shallow-merged into `custom_data` only: fields
Zernio already builds (`value`, `currency`, `contents`,
`num_items`) always win on collision, and `user_data` (hashed
match keys) is never touched. Use first-class fields (e.g.
`user.leadId`) for anything that must reach `user_data`.
ConversionDestination:
type: object
description: |
A discoverable conversion destination on an ad platform: a Meta pixel,
Google conversion action, or LinkedIn conversion rule. Returned by
`listConversionDestinations`, `getConversionDestination`,
`createConversionDestination`, and `updateConversionDestination`.
required: [id, name]
properties:
id:
type: string
description: |
Platform-native identifier. Pass back as `destinationId` on event
send and as the path segment on CRUD endpoints.
name: { type: string }
type:
type: string
description: |
Present when the platform locks the event type/category to the
destination (Google conversion actions, LinkedIn conversion rules).
Absent for Meta pixels (which accept any event name per request).
status:
type: string
enum: [active, inactive]
description: |
For LinkedIn, `inactive` means the rule is soft-deleted (`enabled: false`).
adAccountId:
type: string
description: |
Set by adapters whose destinations are scoped to a specific ad
account (LinkedIn). Pass back on subsequent CRUD calls to
identify the parent ad account.
ConversionAction:
type: object
description: |
A Google Ads conversion action, e.g. a WEBPAGE conversion created via
`createConversionAction`. Returned by `listConversionActions` and
`createConversionAction`.
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
Google generates a snippet for (e.g. WEBPAGE); empty otherwise.
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
single-creative shape (top-level headline / body / imageUrl|video).
`adType` is the union discriminator.
required: [adType, ad, message]
properties:
adType: { type: string, enum: [single] }
ad: { type: object, description: The persisted Ad document. }
message: { type: string }
CtwaMultiResponse:
type: object
description: |
Response returned by `POST /v1/ads/ctwa` when the request used the
multi-creative shape (`creatives[]`). N persisted Ad documents share
the returned `platformCampaignId` and `platformAdSetId`. `adType` is
the union discriminator.
required: [adType, ads, platformCampaignId, platformAdSetId, message]
properties:
adType: { type: string, enum: [multi] }
ads:
type: array
description: |
The persisted Ad documents (one per creative), all sharing the same
`platformCampaignId` and `platformAdSetId`.
items: { type: object }
platformCampaignId: { type: string }
platformAdSetId: { type: string }
message: { type: string }
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
website, send events to, and target ads against. On Meta this is a
Pixel (`kind: pixel`). The shape is platform-neutral so other platforms
(Pinterest Tag, LinkedIn Insight Tag, etc.) can be added without
changing the contract; platform-specific fields are absent where
a platform has no equivalent. Returned by `listTrackingTags`,
`createTrackingTag`, `getTrackingTag`, and `updateTrackingTag`.
required: [id, name, platform, kind, status]
properties:
id:
type: string
description: 'Platform-native tag id. Meta: numeric pixel id, as a string.'
name: { type: string }
platform: { type: string, enum: [metaads] }
kind:
type: string
enum: [pixel, tag, insight_tag]
description: 'Platform-native flavor of the tag (Meta: `pixel`).'
status:
type: string
enum: [active, inactive]
description: '`inactive` when the platform reports the tag as broken/unavailable.'
code:
type: string
description: |
The base-code `<script>` snippet to install on the site. Meta only;
populated by `getTrackingTag`, omitted from the list view.
lastFiredTime:
type: [integer, "null"]
description: |
Unix seconds of the last event the tag received, or `null` if it
never fired. The practical "is it installed and working" signal.
isUnavailable:
type: boolean
description: Whether the tag is in a broken/unavailable state (Meta `is_unavailable`).
installed:
type: boolean
description: "Convenience flag derived from `lastFiredTime`: has the tag ever fired."
creationTime:
type: integer
description: Unix seconds the tag was created.
ownerBusinessId:
type: [string, "null"]
description: |
Business Manager id that owns the tag, or `null` when the tag lives
on a personal (non-BM) ad account. Such tags can't be shared with
other ad accounts.
ownerAdAccountId:
type: string
description: 'Ad account id (`act_...`) that owns the tag, when reported.'
SharedAdAccount:
type: object
description: An ad account a tracking tag is shared with (Meta `shared_accounts` edge).
required: [id]
properties:
id: { type: string, description: 'Ad account id, in `act_<digits>` form.' }
name: { type: string }
businessId: { type: string, description: "Business Manager id that owns the ad account, when reported." }
webhooks:
post.scheduled:
post:
operationId: onPostScheduled
summary: Post scheduled event
description: 'Fired whenever a post enters the scheduled state: created with a schedule, added to a queue, a draft promoted to scheduled or queued, a failed or partial post retried, or a recycled clone created. Not fired when an already-scheduled post is edited or rescheduled.'
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
post.published:
post:
operationId: onPostPublished
summary: Post published event
description: Fired when a post is successfully published.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
post.failed:
post:
operationId: onPostFailed
summary: Post failed event
description: Fired when a post fails to publish on all target platforms.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
post.partial:
post:
operationId: onPostPartial
summary: Post partial event
description: Fired when a post publishes on some platforms and fails on others.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
post.cancelled:
post:
operationId: onPostCancelled
summary: Post cancelled event
description: Fired when a post publishing job is cancelled.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
post.recycled:
post:
operationId: onPostRecycled
summary: Post recycled event
description: 'Fired when a post is recycled (cloned and re-scheduled for publishing). The new clone also fires a post.scheduled event.'
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPost'
responses:
'200':
description: Webhook received successfully
post.platform.published:
post:
operationId: onPostPlatformPublished
summary: Post platform published event
description: |
Fired once per platform target inside a post as that platform finishes
publishing successfully. Does NOT wait for the post-level rollup, so
consumers building incremental UIs get notified immediately, even
when other platforms on the same post are still processing.
The envelope event (`post.published` / `post.partial`) fires
separately AFTER all platforms have terminated. A target that later
fails background reconciliation (e.g. a Facebook video Meta accepted
but never actually published) emits `post.platform.failed` for the
same target afterward.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPostPlatform'
responses:
'200':
description: Webhook received successfully
post.platform.failed:
post:
operationId: onPostPlatformFailed
summary: Post platform failed event
description: |
Fired once per platform target inside a post as that platform fails
permanently. Temporary/retryable failures do NOT fire this event,
only permanent ones do, so retry loops stay quiet. The envelope event
(`post.failed` / `post.partial`) fires separately AFTER all
platforms have terminated. Can also fire a second time for a target
that already emitted `post.platform.published`, if background
reconciliation later discovers the publish never actually completed.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPostPlatform'
responses:
'200':
description: Webhook received successfully
post.platform.deleted:
post:
operationId: onPostPlatformDeleted
summary: Post platform deleted event
description: |
Fired when Zernio's background sync detects that a platform target
published through Zernio was later deleted on the platform (e.g.
the user deleted the Instagram post natively). Detection is
poll-driven (~hourly), not real-time, and fires once per platform
target. `platform.deletedAt` carries the detection time. Detection
is listing-based: a false positive self-heals in Zernio's data when
the post reappears, but the event is not retracted. Coverage is
bounded to the posts the platform listing returns. Detection is a diff
against the posts a prior sync already indexed, so an account for which no
post has ever been indexed can never emit this event, no matter how the
subscription is configured.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPostPlatform'
responses:
'200':
description: Webhook received successfully
post.tiktok.url_resolved:
post:
operationId: onPostTikTokUrlResolved
summary: TikTok post URL resolved event
description: |
Fired when an already-published TikTok platform entry gets its public
URL backfilled. TikTok exposes the numeric video id asynchronously
(often minutes after PUBLISH_COMPLETE), so the terminal events can
carry an empty `publishedUrl` for TikTok. This event delivers
`platform.publishedUrl` and the resolved `platform.platformPostId`
once available. At most once per platform target; never fires for
drafts or private posts (no public URL exists). Payload shape is
identical to `post.platform.published`.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadPostPlatform'
responses:
'200':
description: Webhook received successfully
account.connected:
post:
operationId: onAccountConnected
summary: Account connected event
description: Fired when a 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
discovery + 90-day historical ad backfill) completes. The `sync` block
reports whether the backfill succeeded and how many ads were synced.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadAccountAdsInitialSyncCompleted'
responses:
'200':
description: Webhook received successfully
analytics.synced:
post:
operationId: onAnalyticsSynced
summary: Analytics synced event
description: |
Fired once per connected account each time its analytics sync cycle completes
successfully. Poll-driven (roughly hourly per account), not real-time, and never
fired for a skipped or failed cycle.
A trigger, not a transport: the payload carries no metrics and no cursor. On
receipt, call `GET /v1/analytics/delta` with your own last `nextCursor` to read
every post whose analytics changed, across every account, in one paginated
stream instead of polling analytics once per account.
The feed holds back its most recent few seconds of writes, so a read issued the
instant this event lands often returns an empty page for that account. Poll again
with the same cursor rather than reading an empty page as "nothing changed".
High volume (roughly one delivery per connected account per hour). Subscribe to
it on a dedicated webhook endpoint: a subscription's consecutive-failure count is
shared across all of its events, so an outage while this event is flowing can
suppress the low-volume publishing events on the same subscription.
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
and your app (Meta's `messaging_handovers`), or when the agent is first seen answering
a thread. While `control.owner` is `ai_agent`, inbound messages arrive on
`message.received` with `metadata.standby: true` and the agent's replies on
`message.sent` with `source: meta_business_agent`. Sending any message takes control
back; release it with `POST /v1/inbox/conversations/{conversationId}/thread-control`.
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
contact, in either direction. Works across every DM platform (Instagram, Messenger/Facebook,
Telegram, WhatsApp, X, Reddit, Bluesky). Naturally deduped: a given conversation
only fires this event the very first time it appears.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadConversationStarted'
responses:
'200':
description: Webhook received successfully
call.received:
post:
operationId: onCallReceived
summary: Call received event
description: |
Fired when a WhatsApp Business Call connects. For inbound (UIC) calls
the event fires at the moment our Telnyx trunk bridges the consumer
leg to the customer's forward-to destination; for outbound (BIC)
calls it fires immediately after Meta accepts the connect. Branch on
`call.direction` to distinguish.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadCallReceived'
responses:
'200':
description: Webhook received successfully
call.ended:
post:
operationId: onCallEnded
summary: Call ended event
description: |
Fired on call hangup with the duration and a zero-markup billing
breakdown (Meta cost, Telnyx cost, recording surcharge, total).
Costs are pass-through; no margin is applied.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadCallEnded'
responses:
'200':
description: Webhook received successfully
call.failed:
post:
operationId: onCallFailed
summary: Call failed event
description: |
Fired when a call setup or in-progress call fails (Meta rejected the
connect, Telnyx returned an error, etc.). Payload carries the
upstream error code and message.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadCallFailed'
responses:
'200':
description: Webhook received successfully
call.permission_request:
post:
operationId: onCallPermissionRequest
summary: Call permission request reply event
description: |
Fired when a consumer replies to a `call_permission_request`
interactive message (or its marketing-template variant). Carries
the response (`accept` / `reject`), whether the grant is permanent,
and the expiration timestamp when it is temporary.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadCallPermissionRequest'
responses:
'200':
description: Webhook received successfully
message.sent:
post:
operationId: onMessageSent
summary: Message sent event
description: |
Fired when a message is sent via the API, or from the WhatsApp Business app on
Coexistence numbers. Sends that carry platform-specific context deliver it under
`metadata`, so a quote-reply sent through the API arrives with
`metadata.quotedMessageId` and mirroring CRMs can thread it without a lookup.
Which surfaces actually carry that reference is documented on
`WebhookPayloadMessageSent.metadata.quotedMessageId`; a quote-reply sent from
the WhatsApp Business or Instagram app is not one of them.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadMessageSent'
responses:
'200':
description: Webhook received successfully
message.edited:
post:
operationId: onMessageEdited
summary: Message edited event
description: |
Fired when a sender edits a previously-sent message. Supported on
Instagram, Facebook Messenger, Telegram, and WhatsApp. The payload
includes the full editHistory so consumers can show prior versions.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadMessageEdited'
responses:
'200':
description: Webhook received successfully
message.deleted:
post:
operationId: onMessageDeleted
summary: Message deleted event
description: |
Fired when a sender deletes (unsends) a message. Supported on Instagram
(incoming unsend) and WhatsApp in both directions: an outgoing message
the business deleted (via the Cloud API, or from the WhatsApp Business
app on a Coexistence number) and an incoming message the customer
deleted. Read `message.direction` to tell the two apart. The payload
retains the pre-delete text and attachments so API consumers can access
the original content for moderation or compliance; the Zernio dashboard
UI hides it.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadMessageDeleted'
responses:
'200':
description: Webhook received successfully
message.delivered:
post:
operationId: onMessageDelivered
summary: Message delivered event
description: |
Fired when an outgoing message is delivered to the recipient.
Supported on WhatsApp and Facebook Messenger.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadMessageDeliveryStatus'
responses:
'200':
description: Webhook received successfully
message.read:
post:
operationId: onMessageRead
summary: Message read event
description: |
Fired when an outgoing message is read by the recipient. Supported on
WhatsApp, Facebook Messenger, and Instagram.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadMessageDeliveryStatus'
responses:
'200':
description: Webhook received successfully
message.failed:
post:
operationId: onMessageFailed
summary: Message delivery failed event
description: |
Fired when an outgoing message fails to deliver. Currently only emitted
for WhatsApp (other platforms don't expose per-message failure via
webhook). The payload error object contains code, title, and
message from the platform.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadMessageDeliveryStatus'
responses:
'200':
description: Webhook received successfully
reaction.received:
post:
operationId: onReactionReceived
summary: Reaction received event
description: |
Fired when a participant adds or removes an emoji reaction on a message.
Supported on WhatsApp, Telegram, Slack, Instagram and Facebook Messenger.
Distinct from message.received so a reaction (e.g. a thumbs-up) is not
mistaken for an inbound message.
The `reaction.action` field is `added` or `removed`. On WhatsApp and Meta
removals the platform does not report which emoji was removed, so
`reaction.emoji` may be an empty string.
Instagram and Facebook accounts connected before reactions shipped only
emit this event after their webhook subscription is refreshed; reconnect
the account if reactions never arrive. Requires the Inbox add-on.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadReaction'
responses:
'200':
description: Webhook received successfully
referral.received:
post:
operationId: onReferralReceived
summary: Referral received event
description: |
Fired when someone opens an EXISTING Instagram or Messenger thread
through an attributable entry point - an ig.me / m.me link with a
`ref` parameter, or (Messenger) a returning Click-to-Message ad click -
which Meta delivers as a standalone referral with no message attached.
A referral that rides an inbound message (first message of a thread,
icebreaker taps, returning ad clicks on Instagram) arrives on
`message.received` under `metadata.referral` instead; the two never
fire for the same click. The first referral captured on a conversation
is also persisted on it (see `metadata` on `GET
/v1/inbox/conversations`). Requires the Inbox add-on.
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
Instagram, Facebook, Threads, YouTube, LinkedIn, Bluesky, Reddit and
TikTok. X/Twitter does NOT fire this event. Instagram, Facebook and
TikTok arrive in real time from the platform's own webhook; the rest are
poll-driven, so delivery is not instant. TikTok needs an account
connected through the TikTok for Business app.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadComment'
responses:
'200':
description: Webhook received successfully
review.new:
post:
operationId: onReviewNew
summary: Review new event
description: |
Fired when a new review is posted on a connected account. Currently supported
for Google Business Profile (real-time via Pub/Sub). Requires the Inbox add-on.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadReviewNew'
responses:
'200':
description: Webhook received successfully
review.updated:
post:
operationId: onReviewUpdated
summary: Review updated event
description: |
Fired when a Google Business Profile reviewer edits their review text or rating,
or when a reply is 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. Payload shape matches review.new.
Requires the Inbox add-on.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadReviewUpdated'
responses:
'200':
description: Webhook received successfully
post.external.created:
post:
operationId: onPostExternalCreated
summary: External post created event
description: |
Fired when Zernio's background sync detects a natively-authored post
(created outside Zernio, e.g. a Google Business Profile localPost made in
the Google UI) for the first time. Poll-driven (~hourly), not real-time.
`post.source` is always "external".
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadExternalPost'
responses:
'200':
description: Webhook received successfully
post.external.updated:
post:
operationId: onPostExternalUpdated
summary: External post updated event
description: |
Fired when a tracked native post's text or media changed on the platform.
Detected by comparing text/media structure and, where available, the
platform's own edit timestamp; a media-URL-only refresh does not fire this.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadExternalPost'
responses:
'200':
description: Webhook received successfully
post.external.deleted:
post:
operationId: onPostExternalDeleted
summary: External post deleted event
description: |
Fired when a tracked native post is detected as removed from the platform.
`post.deletedAt` carries the detection time. Coverage is bounded to the
most recent posts the platform listing returns. Detection is a diff against
the posts a prior sync already indexed, so an account for which no post has
ever been indexed can never emit this event, no matter how the subscription
is configured.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadExternalPost'
responses:
'200':
description: Webhook received successfully
lead.received:
post:
operationId: onLeadReceived
summary: Lead received event
description: |
Fired when a new lead is submitted against a Meta Lead Gen (Instant) Form
and ingested via the Page `leadgen` webhook. `lead.fields` is the
question-key to answer map; `lead.formId` / `lead.adId` give provenance.
Requires the Ads add-on.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadLead'
responses:
'200':
description: Webhook received successfully
ad.status_changed:
post:
operationId: onAdStatusChanged
summary: Ad status changed event
description: |
Fired when a campaign, ad set, or ad on a connected ad platform changes status.
Currently emitted only for Meta (`metaads`).
Subscribed to two Meta `ad_account` webhook fields:
- `in_process_ad_objects` - the ad object finished processing and exited
the `IN_PROCESS` state. `status.raw` carries Meta's `status_name`
(e.g. `ACTIVE`, `PAUSED`, `ARCHIVED`, `DELETED`).
- `with_issues_ad_objects` - the ad object entered the `WITH_ISSUES`
state. `status.raw` is set to `WITH_ISSUES` and the `error` block is
populated from Meta's `error_code` / `error_summary` / `error_message`.
`adObject.level` mirrors Meta's `level` and is one of `CAMPAIGN`,
`AD_SET`, or `AD`. Creative-level events are not forwarded.
Branch on `status.raw` to handle each transition; use `error.code` (when
present) as the stable discriminator, since `error.summary` and `error.message`
are localized to the ad-account owner's Meta locale.
The `error` block is optional. It's present on most `WITH_ISSUES`
events but can be absent (Meta does not always include diagnostics),
and is never present on any other status. Always null-check `error`
before reading `error.code`.
**Fan-out:** matching is keyed on `adObject.platformAdAccountId`. When
multiple connected Zernio `metaads` accounts are linked to the same Meta
ad account, each receives its own delivery.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadAdStatusChanged'
examples:
inProcessActive:
summary: in_process_ad_objects → ACTIVE (ad approved)
value:
id: "01J7K3P4N5Q6R7S8T9V0W1X2Y3"
event: ad.status_changed
account:
accountId: "65c8f7e2a1b3c4d5e6f7a8b9"
profileId: "65c8f7e2a1b3c4d5e6f7a800"
platform: metaads
username: acme-ads
displayName: Acme Ads
adObject:
level: AD
platformId: "120244894077860689"
platformAdAccountId: act_2129800524463520
status:
raw: ACTIVE
timestamp: "2026-05-05T15:25:27.944Z"
withIssuesError:
summary: with_issues_ad_objects → WITH_ISSUES (error populated)
value:
id: "01J7K3P4N5Q6R7S8T9V0W1X2Y4"
event: ad.status_changed
account:
accountId: "65c8f7e2a1b3c4d5e6f7a8b9"
profileId: "65c8f7e2a1b3c4d5e6f7a800"
platform: metaads
username: acme-ads
displayName: Acme Ads
adObject:
level: AD
platformId: "120244560555500043"
platformAdAccountId: act_587875401826220
status:
raw: WITH_ISSUES
error:
code: "2643001"
summary: Ad Processing Error
message: "Ad Processing Error: We are having trouble processing your request. To resolve the issue, please try to publish again."
timestamp: "2026-05-08T04:22:35.821Z"
inProcessWithIssues:
summary: in_process_ad_objects → WITH_ISSUES (no error block)
value:
id: "01J7K3P4N5Q6R7S8T9V0W1X2Y5"
event: ad.status_changed
account:
accountId: "65c8f7e2a1b3c4d5e6f7a8b9"
profileId: "65c8f7e2a1b3c4d5e6f7a800"
platform: metaads
username: acme-ads
adObject:
level: AD
platformId: "120244560555500043"
platformAdAccountId: act_587875401826220
status:
raw: WITH_ISSUES
timestamp: "2026-05-08T04:22:35.821Z"
responses:
'200':
description: Webhook received successfully
whatsapp.template.status_updated:
post:
operationId: onWhatsAppTemplateStatusUpdated
summary: WhatsApp template status updated event
description: |
Fired when Meta finishes (re)reviewing a WhatsApp Business template
attached to a connected WABA. Forwarded from Meta's
`message_template_status_update` webhook field on the WhatsApp
Business Account. Consumers branch on `template.status` (APPROVED,
REJECTED, PENDING, PAUSED, DISABLED, IN_APPEAL, PENDING_DELETION).
Meta does not include the previous status or the template's category
in this event.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
$ref: '#/components/schemas/WebhookPayloadWhatsAppTemplateStatusUpdated'
responses:
'200':
description: Webhook received successfully
whatsapp.template.category_updated:
post:
operationId: onWhatsAppTemplateCategoryUpdated
summary: WhatsApp template category updated event
description: |
Fired when Meta reclassifies a WhatsApp Business template's category
after approval. Forwarded from Meta's `template_category_update`
webhook field on the WhatsApp Business Account. Category drives
Meta's per-conversation tariff and whether the template is subject
to the recipient's marketing opt-out. `template.changeType` is
`scheduled` (24h advance notice) or `applied`; `template.category`
is always the category right now.
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
change. Forwarded from Meta's `phone_number_name_update` webhook
field on the WhatsApp Business Account. Fires only on a review
outcome (`name.status` APPROVED, DECLINED, or PENDING_REVIEW); a name
applied without review reports `name_status:
AVAILABLE_WITHOUT_REVIEW` on the phone node instead and produces no
event here. `decision` REJECTED maps to DECLINED and DEFERRED maps to
PENDING_REVIEW, matching the `name_status` vocabulary returned by
`GET /v1/whatsapp/number-info`. Delivery is at-least-once; dedupe on
`(account.accountId, name.status, name.requestedName)`.
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
Embedded Signup; not available for EU/UK/JP businesses) detects a
lead or purchase in a Click-to-WhatsApp conversation. Branch on
`eventName` (`LeadSubmitted` | `Purchase`). Carries the `ctwa_clid`.
Meta omits that clid on a minority of referrals on any number
(coexistence or not, most often WhatsApp Status placements); when it
does, this event can supply it and Zernio writes it back onto the
conversation, so POST /v1/whatsapp/conversions becomes usable for the
thread.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
id: { type: string }
event: { type: string, enum: [whatsapp.automatic_event] }
timestamp:
type: string
format: date-time
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
the synchronous (Tier 1/2) path and the asynchronous regulated (Tier
3/4) path land here. Lets integrators react without polling
GET /v1/phone-numbers.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
id: { type: string }
event: { type: string, enum: [whatsapp.number.activated] }
timestamp:
type: string
format: date-time
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
review. The number is never billed. `reason` carries the reviewer's
rejection reason when available.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
id: { type: string }
event: { type: string, enum: [whatsapp.number.declined] }
timestamp:
type: string
format: date-time
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
regulated number order. The number stays pending (nothing was rejected);
the customer can provide the missing information from the dashboard, or
via the remediation endpoint. `reason` carries the regulator's request
verbatim when available. `requirements` lists every requirement on the
order with the reviewer's current verdict; the `declined` ones are what to
fix, keyed by the same `requirementId` the remediation endpoint uses.
Verdicts only change when a reviewer acts, so they describe the review at
`reviewedAt`, the time of the reviewer's last comment.
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
step (e.g. Onfido). `verificationUrl` is the link to forward to the
number's end user; the order completes once they pass.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
id: { type: string }
event: { type: string, enum: [whatsapp.number.verification_required] }
timestamp:
type: string
format: date-time
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
number stops working until the issue is resolved, after which a
`whatsapp.number.reactivated` event is sent. `reason` carries the cause
(e.g. `payment_failed`, `subscription_ended`).
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
id: { type: string }
event: { type: string, enum: [whatsapp.number.suspended] }
timestamp:
type: string
format: date-time
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
recovered) and is usable again.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
id: { type: string }
event: { type: string, enum: [whatsapp.number.reactivated] }
timestamp:
type: string
format: date-time
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
billing cleanup, or an admin). Terminal. `reason` carries the cause
(e.g. `user_requested`, `cleanup_suspended`).
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
id: { type: string }
event: { type: string, enum: [whatsapp.number.released] }
timestamp:
type: string
format: date-time
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
(POST /v1/phone-numbers/kyc/share). The number enters review
(pending_regulatory) under your account; `whatsapp.number.activated` or
`whatsapp.number.declined` follows once the provider rules on it.
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
id: { type: string }
event: { type: string, enum: [whatsapp.number.kyc_submitted, verification.approved, verification.failed] }
timestamp:
type: string
format: date-time
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
via POST /v1/phone-numbers/stock-watches has deliverable numbers again.
The watch is consumed, so the event fires once per watch; the stock
counts are a snapshot and numbers are sold first come, first served.
Buy with POST /v1/phone-numbers/purchase.
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
the correct code to POST /v1/verify/verifications/{verificationId}/check).
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
id: { type: string }
event: { type: string, enum: [verification.approved] }
timestamp:
type: string
format: date-time
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
of wrong code attempts was reached).
tags: [Webhook Events]
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
id: { type: string }
event: { type: string, enum: [verification.failed] }
timestamp:
type: string
format: date-time
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:
# NOTE: Tools download endpoints (/v1/tools/{platform}/download, /transcript, /hashtag-checker) removed from docs but still functional for existing customers
# ============================================
# Validate
# ============================================
/v1/tools/validate/post-length:
post:
x-resource-group: "publishing"
operationId: validatePostLength
tags: [Validate]
summary: Validate character count
description: |
Check weighted character count per platform and whether the text is within each platform's limit.
X uses weighted counting (URLs = 23 chars via t.co, emojis = 2 chars). All other platforms use plain character length.
Returns counts and limits for all 15 supported platform variants.
X 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.
To 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.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [text]
properties:
text:
type: string
description: The post text to check
example: "Check out https://zernio.com for scheduling posts!"
responses:
"200":
description: Character counts per platform
content:
application/json:
schema:
type: object
properties:
text: { type: string }
platforms:
type: object
additionalProperties:
type: object
properties:
count: { type: integer, description: "Character count for this platform" }
limit: { type: integer, description: "Maximum allowed characters" }
valid: { type: boolean, description: "Whether the text is within the limit" }
example:
twitter: { count: 51, limit: 280, valid: true }
twitterPremium: { count: 51, limit: 25000, valid: true }
instagram: { count: 51, limit: 2200, valid: true }
bluesky: { count: 51, limit: 300, valid: true }
snapchat: { count: 51, limit: 160, valid: true }
"401": { $ref: "#/components/responses/Unauthorized" }
/v1/tools/validate/post:
post:
x-resource-group: "publishing"
operationId: validatePost
tags: [Validate]
summary: Validate post content
description: |
Dry-run the full post validation pipeline without publishing. Catches issues like missing media for Instagram/TikTok/YouTube, hashtag limits, invalid thread formats, Facebook Reel requirements, and character limit violations.
Accepts the same body as POST /v1/posts. Does NOT validate accounts, process media, or track usage. Account lookups are limit-only: a twitter accountId is resolved, scoped to the caller, only to pick the 280 vs 25000 character limit. Missing, foreign, or invalid ids fall back to 280 and never error.
Returns errors for failures and warnings for near-limit content (>90% of character limit).
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platforms]
properties:
content:
type: string
description: Post text content
example: "Check out this video!"
platforms:
type: array
description: Target platforms (same format as POST /v1/posts)
items:
type: object
required: [platform]
properties:
platform:
type: string
enum: [twitter, instagram, tiktok, youtube, facebook, linkedin, bluesky, threads, reddit, pinterest, telegram, snapchat, googlebusiness, discord, slack]
accountId: { type: string, description: 'Account to validate against. For twitter, resolves X Premium status to apply the 25000 character limit instead of 280.' }
customContent: { type: string }
platformSpecificData: { type: object }
customMedia:
type: array
items: { $ref: '#/components/schemas/MediaItem' }
example:
- platform: youtube
- platform: twitter
mediaItems:
type: array
description: Root media items shared across platforms
items: { $ref: '#/components/schemas/MediaItem' }
responses:
"200":
description: Validation result
content:
application/json:
schema:
oneOf:
- type: object
description: Valid post
properties:
valid: { type: boolean }
message: { type: string, example: "No validation issues found." }
warnings:
type: array
items:
type: object
properties:
platform: { type: string }
warning: { type: string }
- type: object
description: Invalid post
properties:
valid: { type: boolean }
errors:
type: array
items:
type: object
properties:
platform: { type: string }
error: { type: string }
warnings:
type: array
items:
type: object
properties:
platform: { type: string }
warning: { type: string }
"401": { $ref: "#/components/responses/Unauthorized" }
/v1/tools/validate/media:
post:
x-resource-group: "publishing"
operationId: validateMedia
tags: [Validate]
summary: Validate media URL
description: |
Check if a media URL is accessible and return metadata (content type, file size) plus per-platform size limit comparisons.
Performs a HEAD request (with GET fallback) to detect content type and size. Rejects private/localhost URLs for SSRF protection.
Platform limits are sourced from each platform's actual upload constraints.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [url]
properties:
url:
type: string
format: uri
description: Public media URL to validate
example: "https://example.com/image.jpg"
responses:
"200":
description: Media validation result
content:
application/json:
schema:
type: object
properties:
valid: { type: boolean }
url: { type: string, format: uri }
error: { type: string, description: "Error message if valid is false" }
contentType: { type: string, example: "image/jpeg" }
size: { type: [integer, "null"], description: "File size in bytes" }
sizeFormatted: { type: string, example: "245 KB" }
type: { type: string, enum: [image, video, unknown] }
platformLimits:
type: object
description: Per-platform size limit comparison (only present when size and type are known)
additionalProperties:
type: object
properties:
limit: { type: integer, description: "Platform size limit in bytes" }
limitFormatted: { type: string }
withinLimit: { type: boolean }
example:
instagram: { limit: 8388608, limitFormatted: "8.0 MB", withinLimit: true }
twitter: { limit: 5242880, limitFormatted: "5.0 MB", withinLimit: true }
bluesky: { limit: 1000000, limitFormatted: "977 KB", withinLimit: true }
"401": { $ref: "#/components/responses/Unauthorized" }
/v1/tools/validate/subreddit:
get:
x-resource-group: "publishing"
operationId: validateSubreddit
tags: [Validate]
summary: Check subreddit existence
description: |
Check if a subreddit exists and return basic info (title, subscriber count, NSFW status, post types allowed).
When accountId is provided, uses authenticated Reddit OAuth API with automatic token refresh (recommended). Falls back to Reddit's public JSON API, which may be unreliable from server IPs. Returns exists: false for private, banned, or nonexistent subreddits.
security:
- bearerAuth: []
parameters:
- name: name
in: query
required: true
description: Subreddit name (with or without "r/" prefix)
schema:
type: string
example: "programming"
- name: accountId
in: query
description: Reddit account ID for authenticated lookup (recommended for reliable results)
schema:
type: string
responses:
"200":
description: Subreddit lookup result
content:
application/json:
schema:
oneOf:
- type: object
description: Subreddit exists
properties:
exists: { type: boolean }
subreddit:
type: object
properties:
name: { type: string, example: "programming" }
title: { type: string, example: "programming" }
description: { type: string, example: "Computer Programming" }
subscribers: { type: integer, example: 6844284 }
isNSFW: { type: boolean }
type: { type: string, enum: [public, private, restricted], example: "public" }
allowImages: { type: boolean }
allowVideos: { type: boolean }
- type: object
description: Subreddit not found
properties:
exists: { type: boolean }
error: { type: string }
"401": { $ref: "#/components/responses/Unauthorized" }
# ============================================
# Analytics
# ============================================
/v1/analytics:
get:
x-resource-group: "analytics"
operationId: getAnalytics
tags: [Analytics]
summary: Get post analytics
description: |
Returns analytics for posts. With postId, returns a single post. Without it, returns a paginated list with overview stats.
Accepts both Zernio Post IDs and External Post IDs (auto-resolved). fromDate defaults to 90 days ago if omitted, max range 366 days.
Single post lookups may return 202 (sync pending) or 424 (all platforms failed). For follower stats, use /v1/accounts/follower-stats.
LinkedIn personal accounts: Analytics are only available for posts published through Zernio. LinkedIn's API only returns metrics for posts authored by the authenticated user. Organization/company page analytics work for all posts.
parameters:
- name: postId
in: query
schema: { type: string }
description: Returns analytics for a single post. Accepts both Zernio Post IDs and External Post IDs. Zernio IDs are auto-resolved to External Post analytics.
- name: platform
in: query
schema: { type: string }
description: Filter by platform (default "all")
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID (default "all")
- name: accountId
in: query
schema: { type: string }
description: Filter by 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
scheduled via Zernio will have isExternal: true in this response.
Use platformPostUrl to correlate with your original Zernio Post IDs.
value:
overview:
totalPosts: 156
publishedPosts: 156
scheduledPosts: 0
lastSync: "2024-11-02T08:30:00Z"
dataStaleness:
staleAccountCount: 0
syncTriggered: false
posts:
- _id: "65f1c0a9e2b5af0012ab34cd"
latePostId: "65f1c0a9e2b5af0012ab34ab"
content: "Check out our new product launch!"
scheduledFor: "2024-11-01T10:00:00Z"
publishedAt: "2024-11-01T10:00:05Z"
status: "published"
analytics:
impressions: 15420
reach: 12350
likes: 342
comments: 28
shares: 45
saves: 0
clicks: 189
views: 0
engagementRate: 2.78
lastUpdated: "2024-11-02T08:30:00Z"
platforms:
- platform: "instagram"
status: "published"
platformPostId: "17902345678901234"
accountId: "64e1f0a9e2b5af0012ab34cd"
accountUsername: "@acmecorp"
analytics:
impressions: 15420
reach: 12350
likes: 342
comments: 28
shares: 45
saves: 0
clicks: 189
views: 0
engagementRate: 2.78
lastUpdated: "2024-11-02T08:30:00Z"
syncStatus: "synced"
platformPostUrl: "https://www.instagram.com/reel/ABC123xyz/"
errorMessage: null
platform: "instagram"
platformPostUrl: "https://www.instagram.com/reel/ABC123xyz/"
isExternal: true
profileId: "64e1f0a9e2b5af0012ab34cd"
thumbnailUrl: "https://storage.example.com/thumb.jpg"
mediaType: "carousel"
mediaItems:
- type: "image"
url: "https://storage.example.com/slide1.jpg"
thumbnail: "https://storage.example.com/slide1.jpg"
- type: "image"
url: "https://storage.example.com/slide2.jpg"
thumbnail: "https://storage.example.com/slide2.jpg"
pagination:
page: 1
limit: 50
total: 156
pages: 4
accounts:
- _id: "64e1f0..."
platform: "twitter"
username: "@acmecorp"
displayName: "Acme Corp"
isActive: true
hasAnalyticsAccess: true
'202':
description: Analytics are being synced from the platform (single post lookup only). The response body matches AnalyticsSinglePostResponse with syncStatus "pending" and a message.
content:
application/json:
schema:
$ref: '#/components/schemas/AnalyticsSinglePostResponse'
'400':
description: Validation error
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: Invalid query parameters }
details: { type: object, description: 'Detailed validation errors' }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: Analytics add-on required }
code: { type: string, example: analytics_addon_required }
'404': { $ref: '#/components/responses/NotFound' }
'424':
description: Post failed to publish on all platforms. Analytics are unavailable. (single post lookup only)
content:
application/json:
schema:
$ref: '#/components/schemas/AnalyticsSinglePostResponse'
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/v1/analytics/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
read, in one paginated stream. Built for integrations that would otherwise call
`GET /v1/analytics` once per connected account. Each page carries changes from
many accounts at once, so your call count scales with how much actually changed
rather than with how many accounts you have. Measured against a fleet of roughly
1,600 connected accounts: about 1,599 per-account analytics calls an hour became
about 205 delta calls an hour, a 7.8x reduction.
**Bootstrap once, then stay in sync.** Take the cursor FIRST: call this endpoint
with NO `cursor` and it answers with an empty `data` array plus the feed's current
position in `nextCursor`. Then load your baseline from `GET /v1/analytics`, the
historical endpoint, because this one is a rolling 7-day change log and cannot
replay history. Then resume from the cursor you took before the baseline. Taking
the cursor afterwards instead drops every change that lands while the baseline is
loading: it is in neither the row you already read nor the feed you resume behind
it. The overlap this order creates is safe, because metrics are absolute values
rather than increments, so draining it leaves every post on its newest value.
`nextCursor` is present on every response, empty pages included, so you always
have something to advance with.
**Ordering.** Entries come back oldest first, in the order the feed received
them. That order is NOT `syncedAt`: `syncedAt` is stamped when an account's sync
cycle started, and a slow cycle writes its rows after a faster cycle that started
later, so `syncedAt` can go backwards between consecutive entries. Do not sort,
filter or resume on it. The cursor is the only stable position, and it is opaque:
pass it back verbatim, and do not parse, construct or compare cursors.
**`hasMore: false` does not mean the feed ended.** This stream has no end and
`nextCursor` is never null. `hasMore: true` means more changes are already
waiting, so call again straight away. `hasMore: false` means you are caught up:
keep the cursor and poll again on your normal interval.
**The newest changes settle before they are served.** The feed deliberately holds
back its last few seconds of writes, so that a row can never become visible
behind a cursor you have already advanced past. A read issued the instant an
`analytics.synced` webhook lands will therefore often return an empty page for
that account. Do not read an empty page as "nothing changed": poll again with the
SAME cursor you last used rather than advancing.
**Repeats inside one instant.** A sync cycle occasionally records the same post
twice at the same feed position. When that happens the feed delivers one of those
rows, not both. Measured over a day of production traffic, about 1.3% of rows
fall in such a group and 99.4% of those groups are identical rows, so this is far
more often deduplication than loss. Metrics are absolute values rather than
increments, so a later entry for the same post supersedes an earlier one.
**Retention is 7 days.** Changes older than that leave the feed. A cursor older
than 6 days is rejected with a `400` (a day of margin, because expiry is lazy).
Recover the same way you bootstrapped: take a fresh cursor from a call to this
endpoint with no `cursor`, then re-load from `GET /v1/analytics`, then resume
from that cursor. A consumer that polls at least daily never reaches this.
Pairs with the `analytics.synced` webhook, so changes can be read on notification
instead of on a timer. That event carries no cursor of its own: keep using the
`nextCursor` this endpoint gave you.
Requires the same analytics access as `GET /v1/analytics`, and shares the
stricter per-second rate-limit window applied to analytics endpoints.
parameters:
- name: cursor
in: query
schema: { type: string }
description: |
Opaque cursor from a previous response's `nextCursor`. Omit it to start from
now: the response is then an empty page carrying the feed's current position.
Rejected with a `400` when malformed, or when older than the retention window.
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
profiles, or the profile sits outside the plan's profile limit (`code`
`PROFILE_OVER_LIMIT`).
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
an application release and the analytics store catching up with it. Nothing is
wrong with the request: retry it unchanged, honouring `Retry-After`. The feed
answers this rather than an empty page, because an empty page is
indistinguishable from "nothing changed" and would advance you past a window
you never received.
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/v1/analytics/youtube/channel-insights:
get:
x-resource-group: "analytics"
operationId: getYouTubeChannelInsights
tags: [Analytics]
summary: Get YouTube channel insights
description: |
Returns channel-scoped aggregate metrics from YouTube Analytics API v2. Saves you
from looping /v1/analytics/youtube/daily-views over every video when you only need
channel totals.
Response shape matches /v1/analytics/instagram/account-insights so the same client
handling works. Requires yt-analytics.readonly scope (412 with reauthorizeUrl if
missing). Data has a 2-3 day delay (endDate is clamped accordingly). Max 89 days,
defaults to last 30 days. Requires the Analytics add-on.
NOT exposed: impressions (Studio thumbnail impressions) and impressionsClickThroughRate.
YouTube Analytics API v2 does not expose these for any principal type, not channel
owners, not Partner Program channels, not content owners with CMS access. The only way
to get them is Studio CSV export. This is a Google-side limitation.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the YouTube account.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list. Defaults to "views,estimatedMinutesWatched,subscribersGained,subscribersLost".
Live YouTube Analytics v2 metrics:
- views
- estimatedMinutesWatched
- averageViewDuration (ratio - weighted mean computed across days)
- subscribersGained
- subscribersLost
Zernio-synthesized from daily follower snapshots (cross-platform parity):
- followers_gained
- followers_lost
- name: since
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: until
in: query
schema: { type: string, format: date }
description: |
End date (YYYY-MM-DD). Defaults to today. YouTube Analytics has a 2-3 day delay,
so the fetch is internally clamped to 3 days ago; any requested range extending
beyond that returns zero values for the tail days. The response's dateRange.until
field reflects your requested value.
- name: metricType
in: query
schema:
type: string
enum: [time_series, total_value]
default: total_value
description: |
"total_value" (default) returns aggregated totals.
"time_series" returns per-day values in the "values" array.
responses:
'200':
description: Channel insights data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramAccountInsightsResponse'
'400':
description: Bad request (invalid accountId / metrics / metricType / date range, or account is not a YouTube account)
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
'404':
description: Account not found
'412':
description: Missing YouTube Analytics scope
content:
application/json:
schema:
$ref: '#/components/schemas/YouTubeScopeMissingResponse'
'503': { $ref: '#/components/responses/TemporarilyUnavailable' }
'502': { $ref: '#/components/responses/UpstreamFailure' }
/v1/analytics/linkedin/org-aggregate-analytics:
get:
x-resource-group: "analytics"
operationId: getLinkedInOrgAggregateAnalytics
tags: [Analytics]
summary: Get LinkedIn org analytics
description: |
Returns aggregate analytics for a LinkedIn organization page. Parallel to
/v1/accounts/{id}/linkedin-aggregate-analytics (which handles personal accounts only).
Backed by LinkedIn's organizationalEntityShareStatistics,
organizationalEntityFollowerStatistics, and organizationPageStatistics endpoints.
Response shape matches /v1/analytics/instagram/account-insights. Max 89 days,
defaults to last 30 days. Requires the Analytics add-on.
Scope requirements: r_organization_social, r_organization_followers, and
r_organization_admin must all be present on the account. Accounts connected before
these scopes were included in the OAuth flow will return 412 with a reauth hint.
Enforced by this endpoint:
- Page-view metrics accept only metricType=total_value (LinkedIn omits per-day
segmentation even when the API is called with DAY granularity, so a time-series
response would be meaningless).
- Date range capped at 89 days.
LinkedIn-side platform limits (not re-enforced here, but worth knowing for larger
ranges in a future release):
- Follower stats: rolling 12-month window, end must be no later than 2 days ago.
- Share stats: rolling 12-month window.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the LinkedIn organization account.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list. Defaults to
"impressions,clicks,engagement_rate,organic_followers_gained,followers_gained,followers_lost".
Share statistics (support both total_value and time_series):
- impressions
- unique_impressions
- clicks
- likes
- comments
- shares
- engagement_rate (0..1, LinkedIn-computed)
Follower-gain statistics (support total_value and time_series):
- organic_followers_gained (per-day organic gains for time_series; sum of organic gains over the range for total_value)
- paid_followers_gained (per-day paid gains for time_series; sum of paid gains over the range for total_value)
Page-view statistics (total_value ONLY - LinkedIn platform limit):
- page_views_total
- page_views_overview
- page_views_careers
- page_views_jobs
- page_views_life
Zernio-synthesized from daily follower snapshots:
- followers_gained
- followers_lost
- name: since
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: until
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to today.
- name: metricType
in: query
schema:
type: string
enum: [time_series, total_value]
default: total_value
responses:
'200':
description: Organization analytics data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramAccountInsightsResponse'
'400':
description: |
Bad request. Common cases:
- Account is a personal LinkedIn account, not organization (code personal_account_not_supported, use /v1/accounts/{id}/linkedin-aggregate-analytics instead)
- Invalid metric name, metricType, or date range
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
'403':
description: |
Platform error. The authenticated member lacks the required
ADMINISTRATOR role on the organization. LinkedIn enforces admin-only
access for all three org statistics endpoints. The error envelope is
type platform_error, and the raw LinkedIn error is echoed in the
platformError field.
'404':
description: Account not found
'412':
description: Missing LinkedIn organization analytics scopes (r_organization_social + r_organization_followers + r_organization_admin)
/v1/analytics/tiktok/account-insights:
get:
x-resource-group: "analytics"
operationId: getTikTokAccountInsights
tags: [Analytics]
summary: Get TikTok account-level insights
description: |
Returns account-level TikTok insights from /v2/user/info/ (live) plus historical
time series joined from Zernio's daily snapshotter (AccountStats).
Response shape matches /v1/analytics/instagram/account-insights. Max 89 days,
defaults to last 30 days. Requires the Analytics add-on and the user.info.stats
scope on the account (412 if missing).
Scope intentionally narrow. TikTok's public API exposes only the four counter
metrics below. The deep metrics that live in TikTok Studio are NOT available on any
public TikTok API, even for Business accounts:
- profile_views
- account-level impressions / reach
- follower inflow / outflow breakdown
- video watch time, average watch time, full-watched rate
- impression_sources (FYP / Following / Hashtag / Search / Personal profile)
TikTok's Research API doesn't expose those fields either, and is restricted to
non-commercial academic use per TikTok's eligibility policy. There is no public
API workaround. Post-level metrics (views, likes, comments, shares per video) are
available via /v1/analytics?postId=... from TikTok's /v2/video/query/.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the TikTok account.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list. Defaults to
"follower_count,likes_count,video_count,followers_gained,followers_lost".
Live from /v2/user/info/ (requires user.info.stats scope):
- follower_count (cumulative; time series joined from AccountStats)
- following_count (cumulative; time series joined from AccountStats.metadata)
- likes_count (cumulative; time series joined from AccountStats.metadata)
- video_count (cumulative; time series joined from AccountStats.metadata)
Zernio-synthesized:
- followers_gained (sum of positive daily follower deltas)
- followers_lost (sum of absolute negative daily deltas)
- name: since
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: until
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to today.
- name: metricType
in: query
schema:
type: string
enum: [time_series, total_value]
default: total_value
description: |
"total_value" returns the latest cumulative counter value.
"time_series" returns daily values joined from AccountStats snapshots.
responses:
'200':
description: Account insights data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramAccountInsightsResponse'
'400':
description: Bad request (invalid accountId / metrics / metricType / date range, or account is not a TikTok account)
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
'404':
description: Account not found
'412':
description: Missing user.info.stats scope
/v1/analytics/youtube/daily-views:
get:
x-resource-group: "analytics"
operationId: getYouTubeDailyViews
tags: [Analytics]
summary: Get YouTube daily views
description: |
Returns daily view counts for a YouTube video including views, watch time, and subscriber changes.
Requires yt-analytics.readonly scope (re-authorization may be needed). YouTube finalizes analytics
with a ~3-day delay; by default only finalized days are returned, and an explicit endDate can reach
into the delay window (see the endDate parameter). Max 90 days, defaults to last 30 days.
parameters:
- name: videoId
in: query
required: true
schema: { type: string }
description: The YouTube video ID (e.g., "dQw4w9WgXcQ")
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio account ID for the YouTube account
- name: startDate
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: endDate
in: query
schema: { type: string, format: date }
description: |
End date (YYYY-MM-DD). Defaults to 3 days ago, the newest fully finalized day
(YouTube finalizes analytics with a ~3-day delay). An explicit endDate is honored
up to today: days inside the delay window are provisional and may still be revised
by YouTube (see provisionalSince in the response), and days YouTube has not
processed yet are omitted from dailyViews.
responses:
'200':
description: Daily views breakdown
content:
application/json:
schema:
$ref: '#/components/schemas/YouTubeDailyViewsResponse'
examples:
success:
summary: Successful response with daily views
value:
success: true
videoId: "dQw4w9WgXcQ"
durationSeconds: 213
dateRange:
startDate: "2025-01-01"
endDate: "2025-01-12"
totalViews: 12345
dailyViews:
- date: "2025-01-12"
views: 1234
estimatedMinutesWatched: 567.5
averageViewDuration: 45.2
averageViewPercentage: 21.2
subscribersGained: 10
subscribersLost: 2
likes: 89
comments: 12
shares: 5
- date: "2025-01-11"
views: 987
estimatedMinutesWatched: 432.1
averageViewDuration: 43.8
averageViewPercentage: 20.6
subscribersGained: 8
subscribersLost: 1
likes: 67
comments: 8
shares: 3
lastSyncedAt: "2025-01-15T12:00:00Z"
scopeStatus:
hasAnalyticsScope: true
'400':
description: Bad request (missing or invalid parameters)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
examples:
missingVideoId:
value:
error: "videoId is required"
invalidDate:
value:
error: "Invalid startDate format. Use YYYY-MM-DD."
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied to this account
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
'412':
description: Missing YouTube Analytics scope
content:
application/json:
schema:
$ref: '#/components/schemas/YouTubeScopeMissingResponse'
examples:
scopeMissing:
summary: YouTube Analytics scope not granted
value:
success: false
error: "To access daily video analytics, please reconnect your YouTube account to grant the required permissions."
code: "youtube_analytics_scope_missing"
scopeStatus:
hasAnalyticsScope: false
requiresReauthorization: true
reauthorizeUrl: "https://accounts.google.com/o/oauth2/auth?client_id=..."
'500':
description: Internal server error
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, example: false }
error: { type: string }
/v1/analytics/youtube/video-retention:
get:
x-resource-group: "analytics"
operationId: getYouTubeVideoRetention
tags: [Analytics]
summary: Get YouTube video retention curve
description: |
Returns the audience retention curve for a single YouTube video, plus the video's
duration for rendering the curve on a time axis. The curve has up to 100 points
(elapsedVideoTimeRatio 0.01-1.0) aggregated over the whole date range; YouTube does
not support per-day retention breakdowns.
audienceWatchRatio is the absolute share of viewers watching at that point in the
video and can exceed 1 (rewinds and looping, common on Shorts). relativeRetentionPerformance
compares against videos of similar length (0 = worst, 0.5 = median, 1 = best).
YouTube returns an empty curve for videos with very few views or before analytics
processing completes (2-3 day delay).
Requires yt-analytics.readonly scope (re-authorization may be needed).
parameters:
- name: videoId
in: query
required: true
schema: { type: string }
description: The YouTube video ID (e.g., "dQw4w9WgXcQ")
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio account ID for the YouTube account
- name: startDate
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to the video's publish date (lifetime curve).
- name: endDate
in: query
schema: { type: string, format: date }
description: |
End date (YYYY-MM-DD). Defaults to 3 days ago, the newest fully finalized day
(YouTube finalizes analytics with a ~3-day delay). An explicit endDate is honored
up to today: days inside the delay window are provisional and may still be revised
by YouTube (see provisionalSince in the response).
responses:
'200':
description: Audience retention curve
content:
application/json:
schema:
$ref: '#/components/schemas/YouTubeVideoRetentionResponse'
examples:
success:
summary: Successful response with retention curve
value:
success: true
accountId: "65f1a2b3c4d5e6f7a8b9c0d1"
videoId: "zF7Gp4jnDa0"
title: "what's your choice?"
publishedAt: "2026-05-20T12:00:00Z"
durationSeconds: 24
dateRange:
startDate: "2026-05-20"
endDate: "2026-06-09"
retentionCurve:
- elapsedVideoTimeRatio: 0.01
audienceWatchRatio: 1.6956
relativeRetentionPerformance: 0.73665
startedWatching: 280331
stoppedWatching: 5348
totalSegmentImpressions: 478466
- elapsedVideoTimeRatio: 0.02
audienceWatchRatio: 1.678
relativeRetentionPerformance: 0.73865
startedWatching: 385
stoppedWatching: 11790
totalSegmentImpressions: 473492
scopeStatus:
hasAnalyticsScope: true
'400':
description: Bad request (missing or invalid parameters)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied to this account
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
'404':
description: Video not found, or it does not belong to this YouTube channel
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Video not found on this YouTube channel" }
type: { type: string, example: "not_found" }
code: { type: string, example: "video_not_found" }
param: { type: string, example: "videoId" }
'412':
description: Missing YouTube Analytics scope
content:
application/json:
schema:
$ref: '#/components/schemas/YouTubeScopeMissingResponse'
examples:
scopeMissing:
summary: YouTube Analytics scope not granted
value:
success: false
error: "To access video retention analytics, please reconnect your YouTube account to grant the required permissions."
code: "youtube_analytics_scope_missing"
scopeStatus:
hasAnalyticsScope: false
requiresReauthorization: true
reauthorizeUrl: "https://accounts.google.com/o/oauth2/auth?client_id=..."
'500':
description: Internal server error
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, example: false }
error: { type: string }
/v1/analytics/facebook/page-insights:
get:
x-resource-group: "analytics"
operationId: getFacebookPageInsights
tags: [Analytics]
summary: Get Facebook Page insights
description: |
Returns page-level Facebook insights (media views, views, post engagements, video metrics,
follower counts). Response shape matches /v1/analytics/instagram/account-insights so the
same client handling works across platforms.
Metric names track the current (post-November 2025) Meta Graph API. The legacy
page_impressions / page_fans / page_fan_adds / page_fan_removes metrics were deprecated
by Meta on November 15, 2025 and are NOT accepted by this endpoint. Use the replacements
below. Because Meta did not provide direct adds/removes replacements, Zernio synthesizes
followers_gained / followers_lost from the daily follower snapshotter.
Max 89 days, defaults to last 30 days. Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the connected Facebook Page.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list of metrics. Defaults to
"page_media_view,page_post_engagements,page_follows,followers_gained,followers_lost".
Live Meta metrics (current names, post-Nov-2025):
- page_media_view (replaces deprecated page_impressions)
- page_views_total
- page_post_engagements
- page_video_views
- page_video_view_time
- page_follows (replaces deprecated page_fans)
Zernio-synthesized from daily follower snapshots (filling the Nov-2025 gap
left by the page_fan_adds / page_fan_removes deprecation):
- followers_gained
- followers_lost
Monetization (opt-in, not in the defaults):
- content_monetization_earnings
- monetization_approximate_earnings
Each monetization metric is fetched with its own separate Graph call, so requesting both
adds two calls. Values are approximate and Meta restates them after the fact.
content_monetization_earnings returns an object per day and always carries unit
"micro_amount" plus an ISO 4217 "currency". monetization_approximate_earnings returns a bare
number per day, so its unit is always "unspecified" and its "currency" is always null. The two
are on different scales and are not comparable to each other. Both keep their daily "values"
on every metricType and are never rescaled by Zernio.
Earnings here are Page-level daily buckets and "total" is their sum. Meta does not
document whether a bucket carries that day's earnings or a running total, and every
Page measured so far earned exactly 0, so reconcile "total" against the Page's own Meta
export before relying on it; the daily "values" are always returned for that purpose.
Per-post lifetime earnings are served by GET /v1/analytics/facebook/post-earnings.
A Page that is not enrolled in monetization, or that earned nothing, returns normal daily
buckets of 0 in "metrics": Meta does not distinguish the two, so a 0 total here does NOT mean
the Page is enrolled. "unavailableMetrics" covers the narrower case where Meta returned no
bucket for the metric at all ("no_data") or rejected the request outright, and the metric is
then omitted from "metrics" rather than reported as 0.
- name: since
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: until
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to today.
- name: metricType
in: query
schema:
type: string
enum: [time_series, total_value]
default: total_value
description: |
"total_value" (default) returns aggregated totals only.
"time_series" returns daily values in the "values" array.
responses:
'200':
description: Page insights data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramAccountInsightsResponse'
examples:
timeSeries:
summary: Time series with computed follower deltas
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "facebook"
dateRange: { since: "2026-03-01", until: "2026-03-22" }
metricType: "time_series"
metrics:
page_media_view:
total: 125000
values:
- { date: "2026-03-01", value: 5400 }
- { date: "2026-03-02", value: 4820 }
followers_gained:
total: 142
values:
- { date: "2026-03-01", value: 7 }
- { date: "2026-03-02", value: 5 }
followers_lost:
total: 23
values:
- { date: "2026-03-01", value: 1 }
- { date: "2026-03-02", value: 0 }
dataDelay: "Meta page insights may be delayed up to 24 hours. Metrics reflect the current (post-November 2025) Graph API names. Monetization earnings are approximate and Meta restates them after the fact."
monetization:
summary: 'Both monetization metrics, each at its own scale'
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "facebook"
dateRange: { since: "2026-06-01", until: "2026-06-30" }
metricType: "total_value"
metrics:
page_media_view:
total: 125000
content_monetization_earnings:
total: 1234000000
unit: "micro_amount"
currency: "USD"
values:
- { date: "2026-06-01", value: 634000000 }
- { date: "2026-06-02", value: 600000000 }
monetization_approximate_earnings:
total: 1234
unit: "unspecified"
currency: null
values:
- { date: "2026-06-01", value: 700 }
- { date: "2026-06-02", value: 534 }
dataDelay: "Meta page insights may be delayed up to 24 hours. Metrics reflect the current (post-November 2025) Graph API names. Monetization earnings are approximate and Meta restates them after the fact."
monetizationUnavailable:
summary: 'A monetization metric Meta returned no bucket for'
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "facebook"
dateRange: { since: "2026-06-01", until: "2026-06-30" }
metricType: "total_value"
metrics:
page_media_view:
total: 125000
unavailableMetrics:
- metric: "content_monetization_earnings"
reason: "no_data"
message: "Meta returned no data for this monetization metric."
dataDelay: "Meta page insights may be delayed up to 24 hours. Metrics reflect the current (post-November 2025) Graph API names. Monetization earnings are approximate and Meta restates them after the fact."
'400':
description: |
Bad request. Common cases:
- Requested a deprecated metric (page_impressions, page_fans, page_fan_adds, page_fan_removes) - use current names instead
- Account has no Page selected (metadata.pageAccessToken missing)
- Invalid accountId / metrics / metricType / date range
- Account is not a Facebook account
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
'404':
description: Account not found
/v1/analytics/facebook/post-earnings:
get:
x-resource-group: "analytics"
operationId: getFacebookPostEarnings
tags: [Analytics]
summary: Get Facebook post monetization earnings
description: |
Returns lifetime monetization earnings for ONE Facebook post, read live from Meta on every
request. Requires the Analytics add-on.
Earnings are CUMULATIVE since the post was published, not earnings within a date range, so
this endpoint takes no since/until and the totals must not be summed across dates or across
posts. Page-level daily earnings live on /v1/analytics/facebook/page-insights.
A post on a Page that is not enrolled in monetization, or that earned nothing, returns
"total": 0 rather than an error: Meta does not distinguish the two. A metric Meta returned no
bucket for at all is reported in "unavailableMetrics" and omitted from "metrics", never as a 0.
Amounts are the platform's raw numbers in the stated "unit" and are never rescaled by Zernio.
Breakdown dimensions are not exposed and a "breakdown" param is rejected with 400. So are
"since", "until", "period", and "metricType": scoping this endpoint to a window is not
possible, and silently returning the lifetime total for one would let a caller sum a year of
weekly requests into a figure ~52x the post's real earnings.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the connected Facebook Page.
- name: postId
in: query
required: true
schema: { type: string }
description: |
The platform post ID, exactly as returned in platformAnalytics[].platformPostId by
/v1/analytics: "{pageId}_{postId}", or the bare video ID for Reels.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list of monetization metrics. Defaults to both:
- content_monetization_earnings
- monetization_approximate_earnings
content_monetization_earnings always carries unit "micro_amount" plus an ISO 4217
"currency". monetization_approximate_earnings is always a bare number, so its unit is
"unspecified" and its "currency" is null. The two are on different scales and are not
comparable to each other. Any other metric name is rejected with 400.
responses:
'200':
description: Lifetime per-post monetization earnings
content:
application/json:
schema:
$ref: '#/components/schemas/FacebookPostEarningsResponse'
examples:
served:
summary: 'Both metrics served, each at its own scale'
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
postId: "123456789_987654321"
platform: "facebook"
period: "lifetime"
metrics:
content_monetization_earnings:
total: 1234000000
unit: "micro_amount"
currency: "USD"
monetization_approximate_earnings:
total: 1234
unit: "unspecified"
currency: null
dataDelay: "Meta restates monetization earnings after the fact. Per-post earnings are lifetime cumulative: they are the total since publication, not earnings within a date range, and must not be summed across dates."
partiallyUnavailable:
summary: 'One metric served, one with no bucket returned by Meta'
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
postId: "123456789_987654321"
platform: "facebook"
period: "lifetime"
metrics:
monetization_approximate_earnings:
total: 0
unit: "unspecified"
currency: null
unavailableMetrics:
- metric: "content_monetization_earnings"
reason: "no_data"
message: "Meta returned no data for this monetization metric."
dataDelay: "Meta restates monetization earnings after the fact. Per-post earnings are lifetime cumulative: they are the total since publication, not earnings within a date range, and must not be summed across dates."
'400':
description: |
Bad request. Common cases:
- Invalid accountId format, or a metric name that is not a monetization metric
- A "breakdown" param was supplied (breakdown dimensions are not exposed)
- A "since", "until", "period", or "metricType" param was supplied (this endpoint returns a lifetime total and takes no date range)
- Account has no Page access token (metadata.pageAccessToken missing)
- Account is not a Facebook account
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
'404':
description: Account not found
/v1/analytics/instagram/account-insights:
get:
x-resource-group: "analytics"
operationId: getInstagramAccountInsights
tags: [Analytics]
summary: Get Instagram insights
description: |
Returns account-level Instagram insights such as reach, views, accounts engaged, and total interactions.
These metrics reflect the entire account's performance across all content surfaces (feed, stories, explore, profile),
and are fundamentally different from post-level metrics. Data may be delayed up to 48 hours.
Max 90 days, defaults to last 30 days. Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the Instagram account
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list of metrics. Defaults to "reach,views,accounts_engaged,total_interactions".
Valid metrics: reach, views, accounts_engaged, total_interactions, comments, likes, saves, shares,
replies, reposts, follows_and_unfollows, profile_links_taps.
Note: only "reach" supports metricType=time_series. All other metrics (including
follows_and_unfollows) are total_value only. This is an Instagram Graph API limitation,
not a Zernio limitation - the IG API does not return time-series data for these metrics.
For a daily running follower count, use /v1/analytics/instagram/follower-history instead.
- name: since
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: until
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to today.
- name: metricType
in: query
schema:
type: string
enum: [time_series, total_value]
default: total_value
description: |
"total_value" (default) returns aggregated totals and supports breakdowns.
"time_series" returns daily values but only works with the "reach" metric.
- name: breakdown
in: query
schema: { type: string }
description: |
Breakdown dimension (only valid with metricType=total_value).
Valid values depend on the metric: media_product_type, follow_type, follower_type, contact_button_type.
responses:
'200':
description: Account insights data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramAccountInsightsResponse'
examples:
timeSeries:
summary: Time series response with daily values
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "instagram"
dateRange:
since: "2026-03-01"
until: "2026-03-22"
metricType: "time_series"
metrics:
reach:
total: 12500
values:
- date: "2026-03-01"
value: 420
- date: "2026-03-02"
value: 385
views:
total: 45000
values:
- date: "2026-03-01"
value: 1520
- date: "2026-03-02"
value: 1380
dataDelay: "Data may be delayed up to 48 hours"
totalValueWithBreakdown:
summary: Total value response with media type breakdown
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "instagram"
dateRange:
since: "2026-03-01"
until: "2026-03-22"
metricType: "total_value"
breakdown: "media_product_type"
metrics:
reach:
total: 12500
breakdowns:
- dimension: "FEED"
value: 5000
- dimension: "REELS"
value: 7500
dataDelay: "Data may be delayed up to 48 hours"
'400':
description: Bad request (invalid parameters)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
examples:
invalidMetric:
value:
error: "Invalid metrics: impressions"
validMetrics: ["accounts_engaged", "comments", "follows_and_unfollows", "likes", "profile_links_taps", "reach", "replies", "reposts", "saves", "shares", "total_interactions", "views"]
breakdownWithTimeSeries:
value:
error: "Breakdowns are only supported with metricType=total_value"
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied to this account
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
'404':
description: Account not found
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Account not found" }
/v1/analytics/instagram/follower-history:
get:
x-resource-group: "analytics"
operationId: getInstagramFollowerHistory
tags: [Analytics]
summary: Get Instagram follower history
description: |
Returns a daily running Instagram follower count time series, served from Zernio's
cross-platform daily snapshotter. Exists because Meta removed follower_count from
the /insights endpoint in Graph API v22+ and never exposed a historical daily series
via any public API.
Response envelope matches /v1/analytics/instagram/account-insights so the same client
handling works. Max 89 days, defaults to last 30 days. Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the Instagram account.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated list. Defaults to "follower_count,followers_gained,followers_lost".
- follower_count : per-day raw follower count
- followers_gained : sum of positive daily deltas
- followers_lost : sum of absolute negative daily deltas
- name: since
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago.
- name: until
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to today.
- name: metricType
in: query
schema:
type: string
enum: [time_series, total_value]
default: total_value
description: |
"total_value" returns aggregated totals (latest for follower_count, sum for gained/lost).
"time_series" returns per-day values in the "values" array.
responses:
'200':
description: Follower history data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramAccountInsightsResponse'
'400':
description: Bad request (invalid accountId / metrics / date range, or account is not an Instagram account)
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
'404':
description: Account not found
/v1/analytics/instagram/demographics:
get:
x-resource-group: "analytics"
operationId: getInstagramDemographics
tags: [Analytics]
summary: Get Instagram demographics
description: |
Returns audience demographic insights for an Instagram account, broken down by age, city, country, and/or gender.
Requires at least 100 followers. Returns top 45 entries per dimension.
Data may be delayed up to 48 hours. Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the Instagram account
- name: metric
in: query
schema:
type: string
enum: [follower_demographics, engaged_audience_demographics]
default: follower_demographics
description: |
"follower_demographics" for follower audience data, or "engaged_audience_demographics" for engaged viewers.
- name: breakdown
in: query
schema: { type: string }
description: |
Comma-separated list of demographic dimensions: age, city, country, gender.
Defaults to all four if omitted.
- name: timeframe
in: query
schema:
type: string
enum: [this_week, this_month]
default: this_month
description: |
Time period for demographic data. Defaults to "this_month".
responses:
'200':
description: Demographic insights data
content:
application/json:
schema:
$ref: '#/components/schemas/InstagramDemographicsResponse'
examples:
allBreakdowns:
summary: All four demographic breakdowns
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "instagram"
metric: "follower_demographics"
timeframe: "last_30_days"
demographics:
age:
- dimension: "25-34"
value: 4500
- dimension: "18-24"
value: 3200
gender:
- dimension: "M"
value: 3000
- dimension: "F"
value: 4800
city:
- dimension: "New York, New York"
value: 800
- dimension: "Los Angeles, California"
value: 650
country:
- dimension: "US"
value: 5000
- dimension: "GB"
value: 1200
note: "Demographics show top 45 entries per dimension. Requires 100+ followers."
'400':
description: Bad request (invalid parameters)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
examples:
invalidBreakdown:
value:
error: "Invalid breakdowns: location"
validBreakdowns: ["age", "city", "country", "gender"]
insufficientFollowers:
value:
success: false
error: "Demographic insights require at least 100 followers."
code: "instagram_insufficient_followers"
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied to this account
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
'404':
description: Account not found
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Account not found" }
/v1/analytics/youtube/demographics:
get:
x-resource-group: "analytics"
operationId: getYouTubeDemographics
tags: [Analytics]
summary: Get YouTube demographics
description: |
Returns audience demographic insights for a YouTube channel, broken down by age, gender, and/or country.
Pass videoId to get the audience profile of a single video instead of the whole channel.
Age and gender values are viewer percentages (0-100). Country values are view counts.
Data is based on signed-in viewers only, with a 2-3 day delay. YouTube suppresses demographics
for videos with too few signed-in views, so low-traffic videos can return empty breakdowns.
Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the YouTube account
- name: videoId
in: query
schema: { type: string }
description: |
YouTube video ID. When provided, demographics are scoped to this single video
(must belong to the connected channel; otherwise 404 video_not_found).
- name: breakdown
in: query
schema: { type: string }
description: |
Comma-separated list of demographic dimensions: age, gender, country.
Defaults to all three if omitted.
- name: startDate
in: query
schema: { type: string, format: date }
description: |
Start date in YYYY-MM-DD format. Defaults to 90 days ago, or to the video's
publish date (lifetime) when videoId is provided.
- name: endDate
in: query
schema: { type: string, format: date }
description: |
End date (YYYY-MM-DD). Defaults to 3 days ago, the newest fully finalized day
(YouTube finalizes analytics with a ~3-day delay). An explicit endDate is honored
up to today: days inside the delay window are provisional and may still be revised
by YouTube (see provisionalSince in the response).
responses:
'200':
description: Demographic insights data
content:
application/json:
schema:
$ref: '#/components/schemas/YouTubeDemographicsResponse'
examples:
allBreakdowns:
summary: All three demographic breakdowns
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "youtube"
demographics:
age:
- dimension: "25-34"
value: 28.5
- dimension: "18-24"
value: 22.1
gender:
- dimension: "male"
value: 62.3
- dimension: "female"
value: 35.8
country:
- dimension: "US"
value: 12000
- dimension: "GB"
value: 3500
dateRange:
startDate: "2026-01-01"
endDate: "2026-03-31"
note: "Age/gender values are viewer percentages (0-100). Country values are view counts. Data based on signed-in viewers only, with 2-3 day delay."
singleVideo:
summary: Demographics scoped to one video (videoId provided)
value:
success: true
accountId: "64e1a2b3c4d5e6f7a8b9c0d1"
platform: "youtube"
videoId: "dQw4w9WgXcQ"
title: "Product launch teaser"
publishedAt: "2026-06-01T09:00:00.000Z"
demographics:
age:
- dimension: "25-34"
value: 41.2
gender:
- dimension: "male"
value: 75.0
- dimension: "female"
value: 25.0
country:
- dimension: "MX"
value: 394
- dimension: "AR"
value: 181
dateRange:
startDate: "2026-06-01"
endDate: "2026-07-13"
note: "Age/gender values are viewer percentages (0-100). Country values are view counts. Data based on signed-in viewers only, with 2-3 day delay."
'400':
description: Bad request (invalid parameters or not a YouTube account)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied to this account
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
'404':
description: 'Account not found, or the video does not exist / does not belong to this YouTube channel'
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Account not found" }
'412':
description: YouTube Analytics scope not granted
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, example: false }
error: { type: string }
code: { type: string, example: "youtube_analytics_scope_missing" }
scopeStatus:
type: object
properties:
hasAnalyticsScope: { type: boolean, example: false }
requiresReauthorization: { type: boolean, example: true }
reauthorizeUrl: { type: string }
'503': { $ref: '#/components/responses/TemporarilyUnavailable' }
'502': { $ref: '#/components/responses/UpstreamFailure' }
/v1/analytics/daily-metrics:
get:
x-resource-group: "analytics"
operationId: getDailyMetrics
tags: [Analytics]
summary: Get daily aggregated metrics
description: |
Returns daily aggregated analytics metrics and a per-platform breakdown.
Each day includes post count, platform distribution, and summed metrics (impressions, reach, likes, comments, shares, saves, clicks, views).
Defaults to the last 180 days. Requires the Analytics add-on.
parameters:
- name: platform
in: query
schema: { type: string }
description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms.
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID. Omit for all profiles.
- name: accountId
in: query
schema: { type: string }
description: Filter by account ID
- name: fromDate
in: query
schema: { type: string, format: date-time }
description: Inclusive start date (ISO 8601). Defaults to 180 days ago.
- name: toDate
in: query
schema: { type: string, format: date-time }
description: Inclusive end date (ISO 8601). Defaults to now.
- name: source
in: query
schema:
type: string
enum: [all, late, external]
default: all
description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.
- name: attribution
in: query
schema:
type: string
enum: [publish, received]
default: publish
description: |
How each post's engagement is attributed to a day.
"publish" (default) sums each post's lifetime total on its publish date.
"received" buckets the per-day increase in engagement by the day it actually arrived (engagement-over-time), so engagement on older posts appears on the day it was gained rather than the post's publish date.
responses:
'200':
description: Daily metrics and platform breakdown
content:
application/json:
schema:
type: object
properties:
dailyData:
type: array
items:
type: object
properties:
date: { type: string, example: "2025-12-01" }
postCount: { type: integer, example: 3 }
platforms:
type: object
additionalProperties: { type: integer }
example: { instagram: 2, twitter: 1 }
metrics:
type: object
properties:
impressions: { type: integer }
reach: { type: integer }
likes: { type: integer }
comments: { type: integer }
shares: { type: integer }
saves: { type: integer }
clicks: { type: integer }
views: { type: integer }
platformBreakdown:
type: array
items:
type: object
properties:
platform: { type: string, example: "instagram" }
postCount: { type: integer, example: 142 }
impressions: { type: integer }
reach: { type: integer }
likes: { type: integer }
comments: { type: integer }
shares: { type: integer }
saves: { type: integer }
clicks: { type: integer }
views: { type: integer }
examples:
success:
value:
dailyData:
- date: "2025-12-01"
postCount: 3
platforms: { instagram: 2, twitter: 1 }
metrics:
impressions: 4520
reach: 3200
likes: 312
comments: 45
shares: 28
saves: 67
clicks: 89
views: 1560
platformBreakdown:
- platform: "instagram"
postCount: 142
impressions: 89400
reach: 62100
likes: 8930
comments: 1204
shares: 567
saves: 2103
clicks: 3402
views: 45200
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
/v1/analytics/best-time:
get:
x-resource-group: "analytics"
operationId: getBestTimeToPost
tags: [Analytics]
summary: Get best times to post
description: |
Returns the best times to post based on historical engagement data.
Groups all published posts by day of week and hour (UTC), calculating average engagement per slot.
Use this to auto-schedule posts at optimal times. Requires the Analytics add-on.
parameters:
- name: platform
in: query
schema: { type: string }
description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms.
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID. Omit for all profiles.
- name: accountId
in: query
schema: { type: string }
description: Filter by account ID. Omit for all accounts.
- name: source
in: query
schema:
type: string
enum: [all, late, external]
default: all
description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.
responses:
'200':
description: Best time slots
content:
application/json:
schema:
type: object
properties:
slots:
type: array
items:
type: object
properties:
day_of_week: { type: integer, description: "0=Monday, 6=Sunday", minimum: 0, maximum: 6 }
hour: { type: integer, description: "Hour in UTC (0-23)", minimum: 0, maximum: 23 }
avg_engagement: { type: number, description: "Average engagement (likes + comments + shares + saves)" }
post_count: { type: integer, description: "Number of posts in this slot" }
examples:
success:
value:
slots:
- day_of_week: 2
hour: 18
avg_engagement: 510.3
post_count: 15
- day_of_week: 0
hour: 9
avg_engagement: 342.5
post_count: 12
- day_of_week: 4
hour: 12
avg_engagement: 289.1
post_count: 8
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
requiresAddon: { type: boolean, example: true }
/v1/analytics/content-decay:
get:
x-resource-group: "analytics"
operationId: getContentDecay
tags: [Analytics]
summary: Get content performance decay
description: |
Returns how engagement accumulates over time after a post is published.
Each bucket shows what percentage of the post's total engagement had been reached by that time window.
Useful for understanding content lifespan (e.g. "posts reach 78% of total engagement within 24 hours").
Requires the Analytics add-on.
parameters:
- name: platform
in: query
schema: { type: string }
description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms.
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID. Omit for all profiles.
- name: accountId
in: query
schema: { type: string }
description: Filter by account ID. Omit for all accounts.
- name: source
in: query
schema:
type: string
enum: [all, late, external]
default: all
description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.
responses:
'200':
description: Content decay buckets
content:
application/json:
schema:
type: object
properties:
buckets:
type: array
items:
type: object
properties:
bucket_order: { type: integer, description: "Sort order (0 = earliest, 6 = latest)" }
bucket_label: { type: string, description: "Human-readable label" }
avg_pct_of_final: { type: number, description: "Average % of final engagement reached (0-100)" }
post_count: { type: integer, description: "Number of posts with data in this bucket" }
examples:
success:
value:
buckets:
- bucket_order: 0
bucket_label: "0-6h"
avg_pct_of_final: 45.2
post_count: 89
- bucket_order: 1
bucket_label: "6-12h"
avg_pct_of_final: 18.7
post_count: 89
- bucket_order: 2
bucket_label: "12-24h"
avg_pct_of_final: 14.1
post_count: 85
- bucket_order: 3
bucket_label: "1-2d"
avg_pct_of_final: 9.3
post_count: 82
- bucket_order: 4
bucket_label: "2-7d"
avg_pct_of_final: 8.1
post_count: 78
- bucket_order: 5
bucket_label: "7-30d"
avg_pct_of_final: 3.8
post_count: 64
- bucket_order: 6
bucket_label: "30d+"
avg_pct_of_final: 0.8
post_count: 41
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
requiresAddon: { type: boolean, example: true }
/v1/analytics/posting-frequency:
get:
x-resource-group: "analytics"
operationId: getPostingFrequency
tags: [Analytics]
summary: Get frequency vs engagement
description: |
Returns the correlation between posting frequency (posts per week) and engagement rate, broken down by platform.
Helps find the optimal posting cadence for each platform. Each row represents a specific (platform, posts_per_week) combination
with the average engagement rate observed across all weeks matching that frequency.
Requires the Analytics add-on.
parameters:
- name: platform
in: query
schema: { type: string }
description: Filter by platform (e.g. "instagram", "tiktok"). Omit for all platforms.
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID. Omit for all profiles.
- name: accountId
in: query
schema: { type: string }
description: Filter by account ID. Omit for all accounts.
- name: source
in: query
schema:
type: string
enum: [all, late, external]
default: all
description: Filter by post origin. "late" for posts published via Zernio, "external" for posts imported from platforms.
responses:
'200':
description: Posting frequency data
content:
application/json:
schema:
type: object
properties:
frequency:
type: array
items:
type: object
properties:
platform: { type: string, example: "instagram" }
posts_per_week: { type: integer, description: "Number of posts published that week" }
avg_engagement_rate: { type: number, description: "Average engagement rate as percentage (0-100)" }
avg_engagement: { type: number, description: "Average raw engagement (likes+comments+shares+saves)" }
weeks_count: { type: integer, description: "Number of calendar weeks observed at this frequency" }
examples:
success:
value:
frequency:
- platform: "instagram"
posts_per_week: 2
avg_engagement_rate: 44.4
avg_engagement: 512
weeks_count: 18
- platform: "instagram"
posts_per_week: 4
avg_engagement_rate: 5.9
avg_engagement: 203
weeks_count: 6
- platform: "facebook"
posts_per_week: 3
avg_engagement_rate: 12.5
avg_engagement: 87
weeks_count: 10
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
requiresAddon: { type: boolean, example: true }
/v1/analytics/post-timeline:
get:
x-resource-group: "analytics"
operationId: getPostTimeline
tags: [Analytics]
summary: Get post analytics timeline
description: |
Returns a daily timeline of analytics metrics for a specific post, showing how impressions, likes,
and other metrics evolved day-by-day since publishing. Each row represents one day of data per platform.
For multi-platform Zernio posts, returns separate rows for each platform. Requires the Analytics add-on.
parameters:
- name: postId
in: query
required: true
schema: { type: string }
description: |
The post to fetch timeline for. Accepts an ExternalPost ID, a platformPostId, or a Zernio Post ID.
- name: fromDate
in: query
schema: { type: string, format: date-time }
description: Start of date range (ISO 8601). Defaults to 90 days ago.
- name: toDate
in: query
schema: { type: string, format: date-time }
description: End of date range (ISO 8601). Defaults to now.
responses:
'200':
description: Daily analytics timeline
content:
application/json:
schema:
type: object
properties:
postId:
type: string
description: The postId that was requested
timeline:
type: array
items:
type: object
properties:
date: { type: string, format: date, description: "Date in YYYY-MM-DD format" }
platform: { type: string, description: "Platform name (e.g. instagram, tiktok)" }
platformPostId: { type: string, description: "Platform-specific post ID" }
impressions: { type: integer, description: "Total impressions on this date" }
reach: { type: integer, description: "Total reach on this date" }
likes: { type: integer, description: "Total likes on this date" }
comments: { type: integer, description: "Total comments on this date" }
shares: { type: integer, description: "Total shares on this date" }
saves: { type: integer, description: "Total saves on this date" }
clicks: { type: integer, description: "Total clicks on this date" }
views: { type: integer, description: "Total views on this date" }
examples:
single_platform:
summary: Single-platform post timeline
value:
postId: "6507a1b2c3d4e5f6a7b8c9d0"
timeline:
- date: "2025-01-15"
platform: "instagram"
platformPostId: "17902345678901234"
impressions: 1200
reach: 980
likes: 45
comments: 3
shares: 12
saves: 8
clicks: 25
views: 0
- date: "2025-01-16"
platform: "instagram"
platformPostId: "17902345678901234"
impressions: 2400
reach: 1850
likes: 92
comments: 7
shares: 21
saves: 15
clicks: 48
views: 0
'400':
description: Missing required postId parameter
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Missing required parameter: postId" }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Forbidden (post belongs to another user or API key scope violation)
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Forbidden" }
'404':
description: Post not found
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Post not found" }
/v1/analytics/googlebusiness/performance:
get:
x-resource-group: "analytics"
operationId: getGoogleBusinessPerformance
tags: [Analytics]
summary: Get Google Business Profile performance metrics
description: |
Returns daily performance metrics for a Google Business Profile location.
Metrics include impressions (Maps/Search, desktop/mobile), website clicks,
call clicks, direction requests, conversations, bookings, and food orders.
Data may be delayed 2-3 days. Max 18 months of historical data.
Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the Google Business Profile account.
- name: metrics
in: query
schema: { type: string }
description: |
Comma-separated metric names. Defaults to all available metrics.
Valid values: BUSINESS_IMPRESSIONS_DESKTOP_MAPS, BUSINESS_IMPRESSIONS_DESKTOP_SEARCH,
BUSINESS_IMPRESSIONS_MOBILE_MAPS, BUSINESS_IMPRESSIONS_MOBILE_SEARCH,
BUSINESS_CONVERSATIONS, BUSINESS_DIRECTION_REQUESTS, CALL_CLICKS, WEBSITE_CLICKS,
BUSINESS_BOOKINGS, BUSINESS_FOOD_ORDERS, BUSINESS_FOOD_MENU_CLICKS
- name: startDate
in: query
schema: { type: string, format: date }
description: Start date (YYYY-MM-DD). Defaults to 30 days ago. Max 18 months back.
- name: endDate
in: query
schema: { type: string, format: date }
description: End date (YYYY-MM-DD). Defaults to today.
responses:
'200':
description: Performance metrics with daily time series
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, example: true }
accountId: { type: string }
platform: { type: string, example: "googlebusiness" }
dateRange:
type: object
properties:
startDate: { type: string, format: date, example: "2026-03-01" }
endDate: { type: string, format: date, example: "2026-03-31" }
metrics:
type: object
description: Each key is a metric name containing total and daily values.
additionalProperties:
type: object
properties:
total: { type: integer, description: "Sum of all daily values in the range" }
values:
type: array
items:
type: object
properties:
date: { type: string, format: date }
value: { type: integer }
dataDelay: { type: string, example: "Data may be delayed 2-3 days" }
examples:
performance_data:
summary: Performance metrics for a location
value:
success: true
accountId: "69300690f43160a0bc999e07"
platform: "googlebusiness"
dateRange:
startDate: "2026-03-01"
endDate: "2026-03-31"
metrics:
WEBSITE_CLICKS:
total: 42
values:
- date: "2026-03-01"
value: 3
- date: "2026-03-02"
value: 1
CALL_CLICKS:
total: 7
values:
- date: "2026-03-01"
value: 1
BUSINESS_IMPRESSIONS_MOBILE_SEARCH:
total: 156
values:
- date: "2026-03-01"
value: 8
dataDelay: "Data may be delayed 2-3 days"
'400':
description: Invalid parameters
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Invalid metrics: INVALID_METRIC" }
validMetrics:
type: array
items: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
'503': { $ref: '#/components/responses/TemporarilyUnavailable' }
'502': { $ref: '#/components/responses/UpstreamFailure' }
/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.
Data is aggregated monthly. Keywords below a minimum impression threshold set by Google are excluded.
Max 18 months of historical data. Requires the Analytics add-on.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The Zernio SocialAccount ID for the Google Business Profile account.
- name: startMonth
in: query
schema: { type: string, pattern: "^\\d{4}-\\d{2}$" }
description: Start month (YYYY-MM). Defaults to 3 months ago.
- name: endMonth
in: query
schema: { type: string, pattern: "^\\d{4}-\\d{2}$" }
description: End month (YYYY-MM). Defaults to current month.
responses:
'200':
description: Search keywords with impression counts
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, example: true }
accountId: { type: string }
platform: { type: string, example: "googlebusiness" }
monthRange:
type: object
properties:
startMonth: { type: string, example: "2026-01" }
endMonth: { type: string, example: "2026-03" }
keywords:
type: array
items:
type: object
properties:
keyword: { type: string, example: "restaurant near me" }
impressions: { type: integer, example: 245 }
note: { type: string, example: "Keywords below a minimum impression threshold are excluded by Google" }
examples:
keywords_data:
summary: Search keywords for a location
value:
success: true
accountId: "69300690f43160a0bc999e07"
platform: "googlebusiness"
monthRange:
startMonth: "2026-01"
endMonth: "2026-03"
keywords:
- keyword: "restaurant near me"
impressions: 245
- keyword: "best tapas barcelona"
impressions: 89
- keyword: "zernio"
impressions: 34
note: "Keywords below a minimum impression threshold are excluded by Google"
'400':
description: Invalid parameters
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Invalid startMonth format. Use YYYY-MM." }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Analytics add-on required" }
code: { type: string, example: "analytics_addon_required" }
'403':
description: Access denied
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Access denied to this account" }
'503': { $ref: '#/components/responses/TemporarilyUnavailable' }
'502': { $ref: '#/components/responses/UpstreamFailure' }
# ─── Inbox analytics ───────────────────────────────────────────────────
# Same `[Analytics]` tag as the posting endpoints above so the rendered
# docs keep posting + inbox analytics centralized in one group. Every
# endpoint here requires `fromDate` (YYYY-MM-DD) and caps the date span
# at 365 days.
/v1/analytics/inbox/volume:
get:
x-resource-group: "analytics"
operationId: getInboxVolume
tags: [Inbox Analytics]
summary: Get inbox messaging volume
description: |
Daily inbox messaging volume + breakdowns. Folds the raw messaging
events into three projections so the client can render the volume
chart, KPI strip, and per-platform stacked bar from a single call.
Max date range is 365 days.
security: [{ bearerAuth: [] }]
parameters:
- name: fromDate
in: query
required: true
schema: { type: string, format: date }
description: Inclusive lower bound (YYYY-MM-DD). Required.
- name: toDate
in: query
schema: { type: string, format: date }
description: Inclusive upper bound (YYYY-MM-DD). Defaults to today.
- name: profileId
in: query
schema: { type: string }
- name: platform
in: query
schema: { type: string }
description: Filter by single platform (facebook, instagram, twitter, etc.).
- name: accountId
in: query
schema: { type: string }
- name: source
in: query
schema: { type: string }
description: "Filter by metadata.source lineage (human, workflow, sequence, broadcast, comment_automation, api, contact, platform)."
responses:
'200':
description: Volume breakdown
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
from: { type: string, format: date }
to: { type: [string, "null"], format: date }
summary:
type: object
properties:
received: { type: integer }
sent: { type: integer }
read: { type: integer }
failed: { type: integer }
uniqueConversations: { type: integer }
timeseries:
type: array
items:
type: object
properties:
date: { type: string, format: date }
sent: { type: integer }
received: { type: integer }
read: { type: integer }
failed: { type: integer }
byPlatform:
type: array
items:
type: object
properties:
platform: { type: string }
sent: { type: integer }
received: { type: integer }
read: { type: integer }
failed: { type: integer }
'400':
description: Validation error
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details: { type: object }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/v1/analytics/inbox/heatmap:
get:
x-resource-group: "analytics"
operationId: getInboxHeatmap
tags: [Inbox Analytics]
summary: Get day × hour heatmap
description: |
Day-of-week × hour-of-day breakdown of inbox messages. Buckets are
sparse: only cells with at least one event are returned; clients
zero-fill the rest to render the full 7×24 grid. The `dow` field
follows ClickHouse's `toDayOfWeek` convention (1 = Monday … 7 =
Sunday). Max date range is 365 days.
security: [{ bearerAuth: [] }]
parameters:
- name: fromDate
in: query
required: true
schema: { type: string, format: date }
- name: toDate
in: query
schema: { type: string, format: date }
- name: profileId
in: query
schema: { type: string }
- name: platform
in: query
schema: { type: string }
- name: accountId
in: query
schema: { type: string }
- name: source
in: query
schema: { type: string }
- name: action
in: query
schema: { type: string, enum: [message.received, message.sent, message.read, all] }
description: Narrow to a single event type. "all" or omitted means no filter.
responses:
'200':
description: Heatmap buckets
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
from: { type: string, format: date }
to: { type: [string, "null"], format: date }
buckets:
type: array
items:
type: object
properties:
dow: { type: integer, minimum: 1, maximum: 7, description: "1 = Monday, 7 = Sunday" }
hour: { type: integer, minimum: 0, maximum: 23 }
received: { type: integer }
sent: { type: integer }
read: { type: integer }
'400':
description: Validation error
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details: { type: object }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/v1/analytics/inbox/source-breakdown:
get:
x-resource-group: "analytics"
operationId: getInboxSourceBreakdown
tags: [Inbox Analytics]
summary: Get inbox source breakdown
description: |
Breakdown of inbox messages by their lineage source (the
`metadata.source` field set at ingest time: human / workflow /
sequence / broadcast / comment_automation / api / contact /
platform). Each source row also carries a per-platform sub-split.
Max date range is 365 days.
security: [{ bearerAuth: [] }]
parameters:
- name: fromDate
in: query
required: true
schema: { type: string, format: date }
- name: toDate
in: query
schema: { type: string, format: date }
- name: profileId
in: query
schema: { type: string }
- name: platform
in: query
schema: { type: string }
- name: accountId
in: query
schema: { type: string }
responses:
'200':
description: Source breakdown
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
from: { type: string, format: date }
to: { type: [string, "null"], format: date }
sources:
type: array
items:
type: object
properties:
source: { type: string }
received: { type: integer }
sent: { type: integer }
read: { type: integer }
byPlatform:
type: array
items:
type: object
properties:
platform: { type: string }
received: { type: integer }
sent: { type: integer }
read: { type: integer }
'400':
description: Validation error
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details: { type: object }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/v1/analytics/inbox/response-time:
get:
x-resource-group: "analytics"
operationId: getInboxResponseTime
tags: [Inbox Analytics]
summary: Get inbox response-time stats
description: |
Time-to-first-response stats. Pairs each received message with the
next sent message in the same conversation and reports the delta
as both summary statistics and a fixed-bucket histogram suited
for the analytics page's TTR chart.
`sampleSize` reflects only conversations that received AND got a
reply in the window. Received-but-never-answered conversations
are excluded. Compare against /v1/analytics/inbox/volume's
`summary.received` to compute reply rate.
Max date range is 365 days.
security: [{ bearerAuth: [] }]
parameters:
- name: fromDate
in: query
required: true
schema: { type: string, format: date }
- name: toDate
in: query
schema: { type: string, format: date }
- name: profileId
in: query
schema: { type: string }
- name: platform
in: query
schema: { type: string }
- name: accountId
in: query
schema: { type: string }
responses:
'200':
description: Response-time summary + histogram
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
from: { type: string, format: date }
to: { type: [string, "null"], format: date }
summary:
type: object
properties:
sampleSize: { type: integer }
medianSeconds: { type: integer }
p90Seconds: { type: integer }
p99Seconds: { type: integer }
meanSeconds: { type: integer }
fastestSeconds: { type: integer }
slowestSeconds: { type: integer }
histogram:
type: array
items:
type: object
properties:
bucket: { type: string, description: "Human label (0-1m, 1-5m, 5-15m, 15-60m, 1-4h, 4-24h, 1d+)" }
lowerSeconds: { type: integer }
upperSeconds: { type: [integer, "null"], description: "null on the open-ended last bucket" }
count: { type: integer }
'400':
description: Validation error
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details: { type: object }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/v1/analytics/inbox/top-accounts:
get:
x-resource-group: "analytics"
operationId: getInboxTopAccounts
tags: [Inbox Analytics]
summary: Get top accounts by inbox volume
description: |
Leaderboard of accounts by inbox message volume. Decorates
each row with display labels from the live SocialAccount record
(so the UI shows username + displayName, not only an ID). Accounts
that no longer map to a SocialAccount surface as "(disconnected)"
so the row stays visible. Max date range is 365 days.
security: [{ bearerAuth: [] }]
parameters:
- name: fromDate
in: query
required: true
schema: { type: string, format: date }
- name: toDate
in: query
schema: { type: string, format: date }
- name: profileId
in: query
schema: { type: string }
- name: platform
in: query
schema: { type: string }
- name: source
in: query
schema: { type: string }
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 50, default: 10 }
description: "Cap on returned rows. Lower than the posting listing's 100 because each row triggers a SocialAccount Mongo lookup."
responses:
'200':
description: Top accounts leaderboard
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
from: { type: string, format: date }
to: { type: [string, "null"], format: date }
accounts:
type: array
items:
type: object
properties:
accountId: { type: string }
platform: { type: string }
displayName: { type: string, description: "(disconnected) when the SocialAccount no longer exists" }
username: { type: string }
received: { type: integer }
sent: { type: integer }
total: { type: integer }
conversations: { type: integer }
medianResponseSeconds: { type: integer }
repliedCount: { type: integer, description: "Distinguishes 'instant replies' from 'no replies at all' so a zero medianResponseSeconds with repliedCount=0 renders as 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
timestamps. The inbox analog of GET /v1/analytics (posts listing):
same filter shape, same pagination, same sort/order semantics.
Use as the entry point for the per-conversation analytics drawer
at /v1/analytics/inbox/conversations/{conversationId}.
Rows are enriched with the conversation's participant info
(`participantName`, `participantUsername`, `participantPicture`)
and last-message preview by joining the Conversation document
scoped to the caller's team. Max date range is 365 days.
security: [{ bearerAuth: [] }]
parameters:
- name: fromDate
in: query
required: true
schema: { type: string, format: date }
- name: toDate
in: query
schema: { type: string, format: date }
- name: profileId
in: query
schema: { type: string }
- name: platform
in: query
schema: { type: string }
- name: accountId
in: query
schema: { type: string }
- name: source
in: query
schema: { type: string }
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
- name: page
in: query
schema: { type: integer, minimum: 1, default: 1 }
- name: sortBy
in: query
schema: { type: string, enum: [lastMessageAt, firstMessageAt, totalMessages, received, sent, read, failed], default: lastMessageAt }
- name: order
in: query
schema: { type: string, enum: [asc, desc], default: desc }
responses:
'200':
description: Paginated conversation analytics list
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
from: { type: string, format: date }
to: { type: [string, "null"], format: date }
items:
type: array
items:
type: object
properties:
conversationId: { type: string, description: "The platformConversationId (the same identity used by metadata.conversationId)" }
mongoId: { type: [string, "null"], description: "The Conversation document _id, when a matching doc exists" }
accountId: { type: string }
platform: { type: string }
participantName: { type: [string, "null"] }
participantUsername: { type: [string, "null"] }
participantPicture: { type: [string, "null"] }
lastMessage: { type: [string, "null"], description: "Cached preview from the Conversation doc" }
totalMessages: { type: integer }
received: { type: integer }
sent: { type: integer }
read: { type: integer }
failed: { type: integer }
firstMessageAt: { type: string, format: date-time }
lastMessageAt: { type: string, format: date-time }
pagination:
type: object
properties:
page: { type: integer }
limit: { type: integer }
total: { type: integer }
totalPages: { type: integer }
hasMore: { type: boolean }
'400':
description: Validation error
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details: { type: object }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/v1/analytics/inbox/conversations/{conversationId}:
get:
x-resource-group: "messages"
operationId: getInboxConversationAnalytics
tags: [Inbox Analytics]
summary: Get conversation analytics
description: |
Per-conversation inbox analytics. The inbox analog of
/v1/analytics/post-timeline: one conversation, daily totals,
source mix.
The {conversationId} path param accepts EITHER the Mongo `_id` of
the Conversation document OR its `platformConversationId` (the
same identity used by metadata.conversationId at ingest time).
Ownership is verified in MongoDB against the caller's team
before the Tinybird query fires.
Max date range is 365 days.
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: Mongo _id or platformConversationId.
- name: fromDate
in: query
required: true
schema: { type: string, format: date }
- name: toDate
in: query
schema: { type: string, format: date }
responses:
'200':
description: Per-conversation analytics
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
conversationId: { type: string, description: "The platformConversationId" }
mongoId: { type: string }
platform: { type: [string, "null"] }
from: { type: string, format: date }
to: { type: [string, "null"], format: date }
summary:
type: object
properties:
received: { type: integer }
sent: { type: integer }
read: { type: integer }
failed: { type: integer }
totalMessages: { type: integer }
firstMessageAt: { type: [string, "null"], format: date-time }
lastMessageAt: { type: [string, "null"], format: date-time }
timeseries:
type: array
items:
type: object
properties:
date: { type: string, format: date }
sent: { type: integer }
received: { type: integer }
read: { type: integer }
failed: { type: integer }
bySource:
type: array
items:
type: object
properties:
source: { type: string, description: "(unspecified) for legacy rows with no metadata.source" }
count: { type: integer }
'400':
description: Validation error
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details: { type: object }
'401': { $ref: '#/components/responses/Unauthorized' }
'404':
description: Conversation not found or not owned by the caller's team
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "Conversation not found." }
code: { type: string, example: conversation_not_found }
'500':
description: Internal server error
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
/v1/account-groups:
get:
x-resource-group: "accounts"
operationId: listAccountGroups
tags: [Account Groups]
summary: List groups
description: |
Returns all account groups visible to the authenticated user. Groups can
contain accounts from multiple profiles. For API keys scoped to specific
profiles, only groups whose accounts all live in allowed profiles are
returned.
responses:
'200':
description: Groups
content:
application/json:
schema:
type: object
properties:
groups:
type: array
items:
type: object
properties:
_id: { type: string }
name: { type: string }
accountIds:
type: array
items: { type: string }
createdBy: { type: string }
profileId:
type: string
description: |
Legacy field. Present only on groups created before
cross-profile groups were supported. New groups omit it.
examples:
example:
value:
groups:
- _id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Marketing Accounts"
accountIds:
- "64e1f0a9e2b5af0012ab34cd"
- "64e1f0a9e2b5af0012ab34ce"
- _id: "6507a1b2c3d4e5f6a7b8c9d1"
name: "Personal Brand"
accountIds:
- "64e1f0a9e2b5af0012ab34cf"
'401': { $ref: '#/components/responses/Unauthorized' }
post:
x-resource-group: "accounts"
operationId: createAccountGroup
tags: [Account Groups]
summary: Create group
description: |
Creates a new account group with a name and a list of account IDs.
Accounts can belong to different profiles; the caller must have access to
every account's profile. Group names must be unique per user.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name, accountIds]
properties:
name: { type: string }
accountIds:
type: array
items: { type: string }
profileId:
type: string
description: |
Deprecated. Accepted for backward compatibility but ignored.
Groups are no longer scoped to a single profile.
deprecated: true
example:
name: "Marketing Accounts"
accountIds:
- "64e1f0a9e2b5af0012ab34cd"
- "64e1f0a9e2b5af0012ab34ce"
responses:
'201':
description: Created
content:
application/json:
schema:
type: object
properties:
message: { type: string }
group:
type: object
properties:
_id: { type: string }
name: { type: string }
accountIds:
type: array
items: { type: string }
example:
message: "Account group created successfully"
group:
_id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Marketing Accounts"
accountIds:
- "64e1f0a9e2b5af0012ab34cd"
- "64e1f0a9e2b5af0012ab34ce"
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { description: Group name already exists }
/v1/account-groups/{groupId}:
put:
x-resource-group: "accounts"
operationId: updateAccountGroup
tags: [Account Groups]
summary: Update group
description: Updates the name or account list of an existing group. You can rename the group, change its accounts, or both.
parameters:
- name: groupId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string }
accountIds:
type: array
items: { type: string }
example:
name: "Updated Marketing Accounts"
accountIds:
- "64e1f0a9e2b5af0012ab34cd"
- "64e1f0a9e2b5af0012ab34ce"
- "64e1f0a9e2b5af0012ab34cf"
responses:
'200':
description: Updated
content:
application/json:
schema:
type: object
properties:
message: { type: string }
group:
type: object
example:
message: "Account group updated successfully"
group:
_id: "6507a1b2c3d4e5f6a7b8c9d0"
name: "Updated Marketing Accounts"
accountIds:
- "64e1f0a9e2b5af0012ab34cd"
- "64e1f0a9e2b5af0012ab34ce"
- "64e1f0a9e2b5af0012ab34cf"
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'409': { description: Group name already exists }
delete:
x-resource-group: "accounts"
operationId: deleteAccountGroup
tags: [Account Groups]
summary: Delete group
description: Permanently deletes an account group. The accounts themselves are not affected.
parameters:
- name: groupId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Deleted
content:
application/json:
schema:
type: object
properties:
message: { type: string }
example:
message: "Account group deleted successfully"
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/media/presign:
post:
x-resource-group: "publishing"
operationId: getMediaPresignedUrl
tags: [Media]
summary: Get upload URL
description: Get a presigned URL to upload files directly to cloud storage (up to 5GB). Returns an uploadUrl and publicUrl. PUT your file to the uploadUrl, then use the publicUrl in your posts.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [filename, contentType]
properties:
filename:
type: string
description: Name of the file to upload
example: "my-video.mp4"
contentType:
$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.
It may have been disconnected or deleted, or it belongs to a profile
the key cannot access. Re-connecting an account issues a NEW account
ID, so an ID stored from before a reconnect will not resolve.
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'429': { $ref: '#/components/responses/RateLimited' }
/v1/reddit/feed:
get:
x-resource-group: "engagement"
operationId: getRedditFeed
tags: [Reddit Search]
summary: Get subreddit feed
description: Fetch posts from a subreddit feed. Supports sorting, time filtering, and cursor-based pagination.
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
- name: subreddit
in: query
schema: { type: string }
- name: sort
in: query
schema: { type: string, enum: [hot, new, top, rising], default: hot }
- name: limit
in: query
schema: { type: integer, default: 25, maximum: 100 }
- name: after
in: query
schema: { type: string }
- name: t
in: query
schema: { type: string, enum: [hour, day, week, month, year, all] }
responses:
'200':
description: Feed items
content:
application/json:
schema:
type: object
properties:
items:
type: array
items:
$ref: '#/components/schemas/RedditPost'
after: { type: [string, "null"] }
before: { type: [string, "null"] }
example:
items:
- id: "1xyz789"
fullname: "t3_1xyz789"
title: "Top marketing trends this week"
author: "trendwatcher"
subreddit: "marketing"
url: "https://www.reddit.com/r/marketing/comments/1xyz789/"
permalink: "https://www.reddit.com/r/marketing/comments/1xyz789/top_marketing_trends/"
score: 892
numComments: 134
createdUtc: 1730100000
over18: false
stickied: false
flairText: null
isGallery: false
- id: "1def456"
fullname: "t3_1def456"
title: "Check out my grow setup"
author: "growthexpert"
subreddit: "gardening"
url: "https://www.reddit.com/gallery/1def456"
permalink: "https://www.reddit.com/r/gardening/comments/1def456/check_out_my_grow_setup/"
score: 567
numComments: 89
createdUtc: 1730050000
over18: false
stickied: false
flairText: null
isGallery: true
galleryImages:
- "https://i.redd.it/abc123.jpg"
- "https://i.redd.it/def456.jpg"
after: "t3_1def456"
before: null
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404':
description: |
No active Reddit account with this ID is available to the API key.
It may have been disconnected or deleted, or it belongs to a profile
the key cannot access. Re-connecting an account issues a NEW account
ID, so an ID stored from before a reconnect will not resolve.
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'429': { $ref: '#/components/responses/RateLimited' }
/v1/billing:
get:
x-resource-group: "billing"
operationId: getBilling
tags: [Usage]
summary: Account billing snapshot (plan, cycle, balance, caps, status)
description: |
The billing "wallet/statement" view: current plan, billing cycle,
accrued balance + remaining credits this period, spend caps, and
payment / access status. This is the billing half of the legacy
`/v1/usage-stats` snapshot. The per-product consumption half is metering
and lives on `GET /v1/usage`.
Accounts on usage-based billing get a populated `balance`; legacy Stripe
accounts get `balance: null` plus a deprecated `legacy.limits` block and,
when payment-blocked, `status.openInvoiceUrl` / `status.declineReason`.
responses:
'200':
description: Billing snapshot
content:
application/json:
schema:
$ref: '#/components/schemas/BillingSnapshot'
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/billing/x-pricing:
get:
x-resource-group: "billing"
operationId: getXApiPricing
tags: [Usage]
summary: Get X API pricing table
description: |
Returns Zernio's canonical X API pricing table. Each X action has its
own billing product and its own rate, and Zernio passes X API costs through
at exact rates with zero markup.
The response is identical for every authenticated user (pricing is universal),
so it is safe to cache on the client for the duration of a billing period.
To compute your own per-operation spend, pair this endpoint with
`GET /v1/usage-stats`, which returns `usage.xApiCallsByOperation`
keyed by the same `operation` field you get here.
responses:
'200':
description: X pricing table
content:
application/json:
schema:
$ref: '#/components/schemas/XApiPricing'
example:
currency: USD
markup: "0%"
source: "https://developer.x.com/#pricing"
lastVerified: "2026-03-26"
tiers:
- { tier: x_api_005, pricePerCallUsd: 0.005, operationCount: 13 }
- { tier: x_api_010, pricePerCallUsd: 0.010, operationCount: 10 }
- { tier: x_api_015, pricePerCallUsd: 0.015, operationCount: 3 }
- { tier: x_api_200, pricePerCallUsd: 0.200, operationCount: 1 }
operations:
- operation: posts_read
eventType: x_posts_read
displayName: "X API: Posts Read"
pricePerCallUsd: 0.005
pricePerCallCents: 0.5
tier: x_api_005
triggeredBy:
- { method: getPostAnalytics, metering: analytics_optin }
- { method: getBatchPostAnalytics, metering: analytics_optin }
- { method: getAccountPosts, metering: analytics_optin }
- operation: content_create
eventType: x_content_create
displayName: "X API: Content Create"
pricePerCallUsd: 0.015
pricePerCallCents: 1.5
tier: x_api_015
triggeredBy:
- { method: publishPost, metering: always }
- operation: content_create_with_url
eventType: x_content_create_with_url
displayName: "X API: Content Create (with URL)"
pricePerCallUsd: 0.200
pricePerCallCents: 20.0
tier: x_api_200
triggeredBy:
- { method: publishPost, metering: always }
- operation: article_draft
eventType: x_article_draft
displayName: "X API: Article Draft"
pricePerCallUsd: 0.010
pricePerCallCents: 1.0
tier: x_api_010
triggeredBy:
- { method: createArticleDraft, metering: always }
- operation: article_publish
eventType: x_article_publish
displayName: "X API: Article Publish"
pricePerCallUsd: 0.010
pricePerCallCents: 1.0
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
compatible:
**Without metering params (the default):** the plan / quota / usage
snapshot: plan name, billing period, limits, usage counts, access
state. Identical to `GET /v1/usage-stats`. Existing integrations keep
working unchanged.
**With `range`, `granularity`, `from`, or `to`:** usage METERING:
billed spend (USD) by product family (`accounts`, `numbers`, `calls`,
`sms`, `dlc`, `xApi`, `credits`, `other`) over the window, at
`day` / `month` / `total` granularity, from the usage-based invoice
breakdown (the CHARGE view, which always reconciles with what gets billed).
Also served at `GET /v1/usage/daily`. Usage-based accounts only:
legacy Stripe accounts get `{ "supported": false, "days": [] }`.
**Attribution (metering mode):** `groupBy=profile|account` adds an
`attribution` breakdown of the window's spend per profile or account,
assembled from your own records and pro-rated against the invoice so
`sum(groups) + unattributed` equals `totals` exactly. `profileId` /
`accountId` instead project the whole payload (`days`, `totals`,
`lineItems`) onto that one group; `peaks`, `callUsage` and `tax` are
then `null` (team-level facts). Projected `days` spread the
group's period share over each day (usage is attributed per period,
not per day). Profile-scoped API keys and members only see their
profiles' groups (`attribution.restricted: true`, with `totals`
summing the visible groups). Credits, 10DLC fees and Verify are always
unattributed. `profileId` / `accountId` on their own do not select
metering mode: pair them with `range`.
For per-domain consumption *volumes* use `GET /v1/usage/calls` and
`GET /v1/usage/sms`. For the billing statement (balance, credits,
caps, payment status) use `GET /v1/billing`.
parameters:
- name: reconcile
in: query
required: false
schema:
type: boolean
description: |
Snapshot mode only. For Stripe subscription users, `true` forces a
subscription reconciliation pass even when cached plan data looks
complete.
- name: range
in: query
required: false
schema:
type: string
enum: [cycle, prev-cycle, 7d, 14d, 30d, 3mo, 12mo, custom]
default: cycle
description: |
Window to report. `cycle` / `prev-cycle` resolve to the customer's
real billing-period bounds (falling back to a trailing 30 days when
no invoice exists yet); `7d`…`12mo` are trailing windows; `custom`
uses `from` / `to`.
- name: from
in: query
required: false
schema: { type: string, format: date }
description: Inclusive start (UTC date). Required when `range=custom`.
- name: to
in: query
required: false
schema: { type: string, format: date }
description: Inclusive end (UTC date). Required when `range=custom`. Max span 366 days.
- name: granularity
in: query
required: false
schema:
type: string
enum: [day, month, total]
default: day
description: |
Bucketing of the `days` series: `day` (one row per UTC day),
`month` (one row per calendar month, dated to the 1st), or `total`
(no series, read `totals`). Does not affect `totals`.
- 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
window (with metering params).
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/UsageStats'
- $ref: '#/components/schemas/UsageMetering'
'400': { description: Invalid query parameter }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/usage-stats:
get:
x-resource-group: "billing"
operationId: getUsageStats
tags: [Usage]
summary: Get plan and usage snapshot (plan, limits, payment status)
deprecated: true
description: |
The plan / quota / payment-status snapshot: current plan name, billing
period, plan limits, usage counts, and access state. Identical to a
bare `GET /v1/usage` call (this path is its deprecated alias). For
billed spend by product, call `GET /v1/usage` with `range` /
`granularity` params. The statement view (balance, credits, caps,
payment status) lives at `GET /v1/billing`.
The response shape depends on the account's `billingSystem`:
* Stripe users: per-period `usage.uploads` / `usage.profiles` counters.
* Usage-based billing users: `usage.connectedAccounts`,
`usage.xApiCallsByOperation` (per-operation X API call counts;
resolve keys via `GET /v1/billing/x-pricing`), plus a `spend`
block with `currentPeriodCents`, `xSpendCents`, and
`xSpendLimitCents`. The legacy `usage.xApiCalls` 3-tier
aggregate is still emitted for back-compat but excludes the
$0.200 URL tier and any future tiers, so new clients should
consume `xApiCallsByOperation` only.
parameters:
- name: reconcile
in: query
required: false
schema:
type: boolean
description: |
For Stripe subscription users, `true` forces a subscription
reconciliation pass even when cached plan data looks complete.
Omit the parameter, or pass `false`, to use the default
first-time-only reconciliation behavior. Invalid boolean values are
rejected.
responses:
'200':
description: Usage stats
content:
application/json:
schema:
$ref: '#/components/schemas/UsageStats'
examples:
stripe:
summary: Stripe subscription user
value:
billingSystem: stripe
planName: "Pro"
billingPeriod: "monthly"
signupDate: "2024-01-15T10:30:00Z"
billingAnchorDay: 15
limits:
uploads: 500
profiles: 10
usage:
uploads: 127
profiles: 3
lastReset: "2024-11-01T00:00:00Z"
hasAccess: true
isInvitedUser: false
autoUpgradeEnabled: false
metronome:
summary: Usage-based billing user
value:
billingSystem: metronome
planName: "Usage-Based"
billingPeriod: "monthly"
limits:
uploads: -1
profiles: -1
usage:
connectedAccounts: 5
# DEPRECATED, kept for back-compat. Excludes the
# $0.200 URL tier; use xApiCallsByOperation.
xApiCalls:
x_api_005: 42
x_api_010: 1
x_api_015: 7
xApiCallsByOperation:
posts_read: 42
content_create: 7
content_create_with_url: 3
dm_event_read: 1
dm_interaction_create: 1
spend:
currentPeriodCents: 1293
creditsRemainingCents: 0
# 42 × $0.005 + 7 × $0.015 + 3 × $0.200 + 1 × $0.010 + 1 × $0.015
# = $0.21 + $0.105 + $0.60 + $0.01 + $0.015 = $0.94 -> 94¢
xSpendCents: 94
xSpendLimitCents: 1000
hasAccess: true
isInvitedUser: false
autoUpgradeEnabled: false
'400': { description: Invalid query parameter }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/usage/calls:
get:
x-resource-group: "billing"
operationId: getCallsUsage
tags: [Usage]
summary: Calling usage and cost
description: |
Aggregated calling usage across your numbers, both channels
(WhatsApp Business Calling + regular phone/PSTN): call counts,
answered counts, minutes, and cost. Use it for cost visibility or to
rebill your own customers per number.
Costs come from each call's billing snapshot, so this endpoint always
agrees with the invoice: `billableUSD` is what Zernio bills;
`metaUSD` is the WhatsApp per-minute charge Meta bills directly to
your WABA (display only, never billed by Zernio).
Optional `groupBy` returns a breakdown by UTC day, by your number, or
by channel. Defaults to the last 30 days.
security:
- bearerAuth: []
parameters:
- { name: since, in: query, schema: { type: string, format: date-time }, description: Start of the window (inclusive). Default 30 days before `until`. }
- { name: until, in: query, schema: { type: string, format: date-time }, description: End of the window (exclusive). Default now. }
- { name: channel, in: query, schema: { type: string, enum: [whatsapp, pstn] } }
- { name: number, in: query, schema: { type: string }, description: "Scope to calls involving this number (typically one of YOUR numbers). E.164, leading + optional." }
- { name: groupBy, in: query, schema: { type: string, enum: [day, number, channel] } }
responses:
'200':
description: Usage totals (+ breakdown when groupBy is set).
content:
application/json:
schema:
type: object
properties:
since: { type: string, format: date-time }
until: { type: string, format: date-time }
groupBy: { type: [string, "null"], enum: [day, number, channel, null] }
totals:
type: object
properties:
calls: { type: integer }
answered: { type: integer }
minutes: { type: number }
billableUSD: { type: number, description: What Zernio bills for these calls. }
metaUSD: { type: number, description: "WhatsApp only: Meta's per-minute charge, billed by Meta directly to your WABA. Display only." }
groups:
type: array
description: Present (possibly empty) when `groupBy` is set.
items:
type: object
properties:
key: { type: string, description: "The group key: a `YYYY-MM-DD` UTC day, one of your numbers, or a channel." }
calls: { type: integer }
answered: { type: integer }
minutes: { type: number }
billableUSD: { type: number }
metaUSD: { type: number }
'400': { description: since must be before until }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/usage/sms:
get:
x-resource-group: "billing"
operationId: getSmsUsage
tags: [Usage]
summary: SMS usage (volumes)
description: |
Aggregated SMS/MMS volumes across your numbers: sent, received, and
total message counts, with an optional breakdown by UTC day or by
number. Defaults to the last 30 days.
Volumes only, deliberately: SMS cost is carrier-rated asynchronously
and billed to your invoice, so per-message cost is not available here.
Calling usage (GET /v1/usage/calls) does include billable cost.
security:
- bearerAuth: []
parameters:
- { name: since, in: query, schema: { type: string, format: date-time }, description: Start of the window (inclusive). Default 30 days before `until`. }
- { name: until, in: query, schema: { type: string, format: date-time }, description: End of the window (exclusive). Default now. }
- { name: number, in: query, schema: { type: string }, description: "Scope to one of YOUR SMS-enabled numbers (E.164, leading + optional)." }
- { name: groupBy, in: query, schema: { type: string, enum: [day, number] } }
responses:
'200':
description: Volume totals (+ breakdown when groupBy is set).
content:
application/json:
schema:
type: object
properties:
since: { type: string, format: date-time }
until: { type: string, format: date-time }
groupBy: { type: [string, "null"], enum: [day, number, null] }
totals:
type: object
properties:
sent: { type: integer }
received: { type: integer }
total: { type: integer }
groups:
type: array
description: Present (possibly empty) when `groupBy` is set.
items:
type: object
properties:
key: { type: string, description: "A `YYYY-MM-DD` UTC day or one of your numbers." }
sent: { type: integer }
received: { type: integer }
total: { type: integer }
'400': { description: since must be before until }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "`number` doesn't match any of your SMS-enabled numbers" }
/v1/posts:
get:
x-resource-group: "publishing"
operationId: listPosts
tags: [Posts]
summary: List posts
description: Returns a paginated list of posts. Published posts include platformPostUrl with the public URL on each platform.
parameters:
- $ref: '#/components/parameters/PageParam'
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 500, default: 10 }
description: 'Page size. Values above the maximum return 400 rather than being clamped.'
- name: source
in: query
schema: { type: string, enum: [zernio, external], default: zernio }
description: >-
Which collection to read. `zernio` (default) returns posts authored
through Zernio. `external` returns posts synced from the platform
(existing/historical posts that were published outside Zernio).
Combine with `accountId` and paginate via `page`/`limit` to walk the
full synced history (we keep up to the last ~12 months per account).
- name: status
in: query
schema: { type: string, enum: [draft, scheduled, 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.
`content` is optional in four cases:
- media is attached
- all platforms have `customContent`
- every platform entry is an X Article (`platformSpecificData.article`)
- every platform entry is a LinkedIn text-free reshare (`platformSpecificData.reshareUrl` with no text)
See each platform's schema for media constraints.
## Scheduling
Pick one of:
- `scheduledFor`: publish at the scheduled time
- `publishNow: true`: publish synchronously, inside this request
- `queuedFromProfile`: publish in the profile's next queue slot
With none of them and `isDraft` unset, the post is saved as a draft. `platforms` is required unless the post is a draft.
Precedence: `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`.
## Idempotency
Two layers of duplicate-protection apply, so safe-to-retry callers (network blips, n8n / Zapier retries, etc.) don't accidentally double-post.
**1. Same-request idempotency (5-minute window).**
Pass an `x-request-id` header to mark a logical request. If a second request arrives with the same `x-request-id` while the first is in-flight (or within ~5 minutes of completion), we return **HTTP 200** with the original post in the `existingPost` field, and no new post is created.
The 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:
- Set a unique `x-request-id` per logical call (recommended, UUIDv4 is fine)
- Or omit the header, and we'll treat each request as new
**Common pitfall**: if your workflow tool uses a single execution-level request ID and reuses it across multiple HTTP nodes (e.g. one ID for the whole run, shared across 6 different platform calls), every call after the first will look like a retry of the first and return its post. Generate a fresh ID per node.
**2. Content-hash dedup (24-hour window).**
Independently, we hash `(platform, accountId, content + media URLs)` and reject duplicates within 24 hours with **HTTP 409**. This catches genuine "same content posted twice to the same account" cases regardless of `x-request-id`. The response carries `error`, `accountId`, `platform`, and `existingPostId` so you can find the original.
To intentionally re-post identical content within 24h, change something (the caption, the media, the account), because the dedup is keyed on the full content fingerprint.
Order: same-`x-request-id` retries (200) are checked first; if no idempotency match, the content-hash dedup (409) runs.
parameters:
- name: x-request-id
in: header
required: false
schema: { type: string, format: uuid }
description: |
Optional client-generated request identifier for safe retry (idempotency). When two requests carry the same value, the second is treated as a retry of the first and returns the original post (HTTP 200) instead of creating a duplicate. Window is ~5 minutes from the first request. Generate a UUID per logical call. SDKs do this automatically; HTTP clients should set it themselves or omit it. See the operation description for the full idempotency contract.
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title:
type: string
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.
Only used when queuedFromProfile is also provided.
If omitted, uses the profile's default queue.
examples:
twitterArticle:
summary: Publish a long-form X Article
description: |
Creates an X Article draft and publishes it. This normally makes two
billable X API calls at $0.010 each ($0.020 total). The account must
be eligible for X Articles (X Premium+). Article media is supplied as
public URLs; Zernio uploads it to X and builds the native media shape.
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
and optional headline. mediaItems and carouselCards must have the same
length, in the same order. Images only (no video cards).
value:
content: "Check out our new inventory"
mediaItems:
- url: "https://cdn.example.com/car-1.jpg"
type: image
- url: "https://cdn.example.com/car-2.jpg"
type: image
- url: "https://cdn.example.com/car-3.jpg"
type: image
platforms:
- platform: facebook
accountId: "64e1f0a9e2b5af0012ab34cd"
publishNow: true
facebookSettings:
carouselLink: "https://example.com/inventory"
carouselCards:
- link: "https://example.com/inventory/car-1"
name: "2024 Sedan"
description: "Low miles"
- link: "https://example.com/inventory/car-2"
name: "2023 SUV"
description: "Certified pre-owned"
- link: "https://example.com/inventory/car-3"
name: "2024 Truck"
description: "Loaded"
facebookTextBackground:
summary: Facebook colored-background text post
description: |
Renders the post as large text on a Facebook-defined colored background.
Text-only: mediaItems and carouselCards are rejected, and contentType
must not be story or reel.
value:
content: "We just shipped something big."
platforms:
- platform: facebook
accountId: "64e1f0a9e2b5af0012ab34cd"
publishNow: true
facebookSettings:
textFormatPresetId: "1881421442117417"
recyclingPost:
summary: Post with weekly recycling and content variations
value:
content: "Check out our evergreen guide!"
platforms:
- platform: twitter
accountId: "64e1f0a9e2b5af0012ab34cd"
scheduledFor: "2025-06-01T10:00:00Z"
recycling:
gap: 2
gapFreq: week
expireCount: 6
contentVariations:
- "Check out our evergreen guide!"
- "Don't miss our essential guide!"
- "Our most popular guide, updated!"
tiktokPhotoCarousel:
summary: TikTok photo carousel (Creator Inbox draft)
description: |
Sends photos to TikTok Creator Inbox as a draft. The creator receives an inbox
notification and completes the post via TikTok's editing flow. Uses draft: true
which maps to TikTok API post_mode MEDIA_UPLOAD. Note: publish_type is not a
supported field; use draft instead.
value:
content: "Check out these photos!"
mediaItems:
- type: image
url: "https://example.com/photo1.jpg"
- type: image
url: "https://example.com/photo2.jpg"
platforms:
- platform: tiktok
accountId: "64e1f0a9e2b5af0012ab34cd"
tiktokSettings:
draft: true
privacyLevel: "PUBLIC_TO_EVERYONE"
allowComment: true
photoCoverIndex: 0
autoAddMusic: false
contentPreviewConfirmed: true
expressConsentGiven: true
tiktokPhotoDirect:
summary: TikTok photo carousel (direct publish)
description: |
Publishes photos directly to TikTok. With draft omitted or false, the post is
published immediately via TikTok API post_mode DIRECT_POST.
value:
content: "Check out these photos!"
mediaItems:
- type: image
url: "https://example.com/photo1.jpg"
- type: image
url: "https://example.com/photo2.jpg"
platforms:
- platform: tiktok
accountId: "64e1f0a9e2b5af0012ab34cd"
tiktokSettings:
privacyLevel: "PUBLIC_TO_EVERYONE"
allowComment: true
photoCoverIndex: 0
autoAddMusic: false
contentPreviewConfirmed: true
expressConsentGiven: true
tiktokVideo:
summary: TikTok video post (direct publish)
value:
content: "New video is live!"
mediaItems:
- type: video
url: "https://example.com/video.mp4"
platforms:
- platform: tiktok
accountId: "64e1f0a9e2b5af0012ab34cd"
tiktokSettings:
privacyLevel: "PUBLIC_TO_EVERYONE"
allowComment: true
allowDuet: true
allowStitch: true
commercialContentType: "none"
contentPreviewConfirmed: true
expressConsentGiven: true
tiktokVideoDraft:
summary: TikTok video post (Creator Inbox draft)
description: |
Sends a video to TikTok Creator Inbox as a draft. Video drafts use a dedicated
TikTok endpoint (/v2/post/publish/inbox/video/init/) that only accepts source_info,
so post_info fields (privacyLevel, allowComment, etc.) are set by the creator
during TikTok's editing flow.
value:
content: "New video draft!"
mediaItems:
- type: video
url: "https://example.com/video.mp4"
platforms:
- platform: tiktok
accountId: "64e1f0a9e2b5af0012ab34cd"
tiktokSettings:
draft: true
contentPreviewConfirmed: true
expressConsentGiven: true
multiPlatform:
summary: Multi-platform post (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
cap detail, only a per-account go/no-go and a reason.
The schema is a union only so generated clients can type both success shapes of this
operation: a 200 is always the dry-run verdict, and a created post is always a 201.
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.
**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.
Tell the outcomes apart with `post.status`:
- `partial` - at least one platform published and at least one failed. Per-platform detail is in `platformResults` and in `post.platforms[]`.
- `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.
- `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.
A 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.
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:
- `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.
- `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`.
- `PROFILE_OVER_LIMIT`: a target account belongs to a profile beyond the plan's profile limit.
- No `code`: a target `accountId` does not belong to the authenticated user (or is outside the API key's profile scope).
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: 'Account 6a0f6d2e520992756d96bb6c (facebook "My Page") is disconnected and cannot be posted to. Facebook tokens expired. Please reconnect your Facebook account. After reconnecting, refresh your account IDs from GET /v1/accounts.' }
code: { type: string, enum: [ACCOUNT_DISCONNECTED, 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).
Body fields:
- `error`: human-readable message
- `details.accountId`: the account that already has this content
- `details.platform`: the platform that already has this content
- `details.existingPostId`: Zernio `_id` of the original post
To intentionally re-post identical content within 24h, vary the content fingerprint (change the caption, swap a media item, or use a different account). To avoid 409s caused by retry loops, set a unique `x-request-id` per logical request. See `parameters.x-request-id` above.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: "This exact content is already scheduled, publishing, or was posted to this account within the last 24 hours." }
details:
type: object
properties:
accountId: { type: string }
platform: { type: string }
existingPostId: { type: string }
'429':
description: "Rate limit exceeded. Possible causes: API rate limit, velocity limit (25 posts/hour per account), account cooldown, or daily platform limits."
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details:
type: object
description: Additional context about the rate limit
headers:
Retry-After:
description: Seconds until the rate limit resets (for API rate limits)
schema: { type: integer }
X-RateLimit-Limit:
description: The rate limit ceiling
schema: { type: integer }
X-RateLimit-Remaining:
description: Requests remaining in current window
schema: { type: integer }
X-RateLimit-Reset:
description: Unix timestamp (seconds since epoch) when the next slot frees up in the sliding window
schema: { type: integer }
/v1/posts/sync-external:
post:
x-resource-group: "publishing"
operationId: syncExternalPosts
tags: [Analytics]
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).
Primary use case: verifying a submitted post. When a user publishes on the platform and immediately pastes the post URL into your app, call this with `accountId` plus `url` (or `postId`) to confirm the post exists and return its metadata.
Behavior:
- Account access and connection state are checked before any platform call, including requests inside the debounce window.
- 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.
- For connected accounts, we fetch the latest posts live from the platform, then match and return the submitted post.
- Requests are debounced per account (~15s): if the account was synced inside that window, the live fetch is skipped.
`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).
LinkedIn personal profiles: LinkedIn has no listing API for personal profiles, so a `url` is REQUIRED and imports that single post. Pass any LinkedIn post URL (`linkedin.com/posts/…`, `linkedin.com/feed/update/urn:li:activity:…`) or a `urn:li:share:…` / `urn:li:ugcPost:…` URN. Works for posts published outside Zernio and before the account was connected, any age; the post must be authored by the connected member. Imported posts return full analytics (impressions, reach, reactions, comments, reshares, saves) and keep refreshing on the background analytics cycle, but carry no content/media (LinkedIn does not expose them for personal profiles).
`url` accepts any format the platform uses (e.g. `instagram.com/p/…`, `instagram.com/reel/…`, `youtu.be/…`, `youtube.com/shorts/…`, `tiktok.com/@user/video/…`, `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.
Note: post-level analytics (reach, impressions) still carry the platform's own delay (e.g. ~24h on Instagram). This endpoint confirms the post exists and returns its metadata plus basic engagement (likes, comments), not delayed insights.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId:
type: string
description: SocialAccount ID whose posts to sync. Must be connected to Zernio.
url:
type: string
description: The post URL to locate. Optional. Provide `url` or `postId` to return a specific post; omit both to refresh and return the account's recent posts.
postId:
type: string
description: The platform post/media/video id to locate, as an alternative to `url`. Optional.
responses:
'200':
description: |
Sync result. When `url` or `postId` is provided, returns the matched post (or `found: false`). When neither is provided, returns the account's freshly-synced recent posts.
content:
application/json:
schema:
type: object
properties:
synced:
type: object
properties:
postsFound: { type: integer, description: Posts returned by the platform listing during the on-demand sync }
postsSynced: { type: integer, description: Posts inserted or updated in Zernio }
skipped: { type: boolean, description: "True when 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'
'404':
description: Account not found (or not owned by the authenticated user)
content:
application/json:
schema:
$ref: '#/components/schemas/ErrorResponse'
'503': { $ref: '#/components/responses/TemporarilyUnavailable' }
'502': { $ref: '#/components/responses/UpstreamFailure' }
'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.' }
'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
/v1/posts/{postId}:
get:
x-resource-group: "publishing"
operationId: getPost
tags: [Posts]
summary: Get post
description: |
Fetch a single post by ID. For published posts, this returns platformPostUrl for each platform.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Post
content:
application/json:
schema:
$ref: '#/components/schemas/PostGetResponse'
examples:
scheduledPost:
summary: Scheduled post (pending)
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
title: "Launch post"
content: "We just launched!"
status: "scheduled"
scheduledFor: "2024-11-01T10:00:00Z"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0..."
platform: "twitter"
username: "@acme"
displayName: "Acme Corp"
isActive: true
status: "pending"
publishedPost:
summary: Published post with platformPostUrl
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
title: "Launch post"
content: "We just launched!"
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0a9e2b5af0012ab34de"
platform: "twitter"
username: "@acmecorp"
displayName: "Acme Corporation"
isActive: true
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
platformPostId: "1852634789012345678"
platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678"
- platform: "linkedin"
accountId:
_id: "64e1f0a9e2b5af0012ab34ef"
platform: "linkedin"
username: "acme-corporation"
displayName: "Acme Corporation"
isActive: true
status: "published"
publishedAt: "2024-11-01T10:00:06Z"
platformPostId: "urn:li:share:7123456789012345678"
platformPostUrl: "https://www.linkedin.com/feed/update/urn:li:share:7123456789012345678"
failedPost:
summary: Failed post with error details
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
content: "This post failed to publish"
status: "failed"
platforms:
- platform: "instagram"
accountId:
_id: "64e1f0a9e2b5af0012ab34de"
platform: "instagram"
username: "acmecorp"
isActive: false
status: "failed"
errorMessage: "Instagram access token has expired. Please reconnect your account."
errorCategory: "auth_expired"
errorSource: "user"
partialPost:
summary: Partial success (some platforms failed)
value:
post:
_id: "65f1c0a9e2b5af0012ab34cd"
content: "Launch announcement!"
status: "partial"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0a9e2b5af0012ab34de"
platform: "twitter"
username: "@acmecorp"
isActive: true
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
platformPostId: "1852634789012345678"
platformPostUrl: "https://twitter.com/acmecorp/status/1852634789012345678"
- platform: "threads"
accountId:
_id: "64e1f0a9e2b5af0012ab34ef"
platform: "threads"
username: "acmecorp"
isActive: true
status: "failed"
errorMessage: "Post text exceeds the 500 character limit for Threads."
errorCategory: "user_content"
errorSource: "user"
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Forbidden
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
put:
x-resource-group: "publishing"
operationId: updatePost
tags: [Posts]
summary: Update post
description: |
Update an existing post. Draft, scheduled, failed, partial, and cancelled posts can be edited.
Published posts can only have their recycling config updated.
To promote a draft to scheduled, send `isDraft: false` together with `scheduledFor` (or `publishNow: true`,
or `queuedFromProfile`). If `isDraft` is omitted the post keeps its current draft status, so sending only
`scheduledFor` to a draft returns 200 but the post remains a draft.
Non-draft updates run the same per-platform validation as post creation (media requirements, platform-specific
field rules, etc.) against the resulting platforms, returning 400 on failure.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
title:
type: string
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.
**207 is a 2xx status**, so `fetch(...).ok` is `true` and axios resolves. Branch on the status code explicitly.
Read `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.
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.
CSV columns:
- 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`).
- Content: at least one of `post_content`, `title`, or `media_urls` is required.
- 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.
- `content` aliases `post_content`
- `timezone` aliases `tz`
- `scheduledFor` aliases `schedule_time`
- `mediaUrls` aliases `media_urls`
- 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>`.
- 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.
- 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.
Example row (header + one data row):
```
post_content,platforms,profiles,schedule_time,tz
"Hello world",instagram,MyProfile,2026-09-01 10:00,America/New_York
```
parameters:
- name: dryRun
in: query
schema: { type: boolean, default: false }
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
properties:
file:
type: string
format: binary
responses:
'200':
description: |
Bulk upload results. Returned when every row succeeded (or every row failed).
A mix of successes and failures returns `207` instead, with the same body shape.
content:
application/json:
schema:
$ref: '#/components/schemas/BulkUploadResult'
example:
total: 1
valid: 1
invalid: 0
results:
- rowIndex: 1
ok: true
createdPostId: "69df8c12f102e11169c53cd3"
warnings: []
'207':
description: |
Partial success: some rows were created and some failed. Body is identical in
shape to the `200` response. Inspect each entry in `results` (`ok` plus `errors`)
to see which rows failed and why.
content:
application/json:
schema:
$ref: '#/components/schemas/BulkUploadResult'
example:
total: 1
valid: 0
invalid: 1
results:
- rowIndex: 1
ok: false
errors:
- "unknown_profile:693ae023a552465131f3bdca1"
- "no_account_for_platform:tiktok"
warnings: []
'400':
description: Invalid CSV or validation errors
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: 'Payment required: the account owner has a failed payment. Not returned on dry-run.'
content:
application/json:
schema:
type: object
properties:
error: { type: string }
'404':
description: Authenticated user not found
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'429':
description: |
Rate limit exceeded. Possible causes: API rate limit (requests per minute) or account cooldown (one or more accounts for platforms specified in the CSV are temporarily rate-limited).
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details:
type: object
/v1/posts/{postId}/retry:
post:
x-resource-group: "publishing"
operationId: retryPost
tags: [Posts]
summary: Retry failed post
description: Immediately retries publishing a failed post. Returns the updated post with its new status.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Retry successful
content:
application/json:
schema:
$ref: '#/components/schemas/PostRetryResponse'
example:
message: "Post published successfully"
post:
_id: "65f1c0a9e2b5af0012ab34cd"
content: "Check out our new product!"
status: "published"
publishedAt: "2024-11-01T10:00:05Z"
platforms:
- platform: "twitter"
accountId:
_id: "64e1f0..."
platform: "twitter"
username: "@acme"
displayName: "Acme Corp"
isActive: true
status: "published"
platformPostId: "1234567890"
platformPostUrl: "https://twitter.com/acme/status/1234567890"
'207':
description: |
The retry ran, but publishing did not fully succeed. Covers both a partial publish and a retry in which no platform published.
**207 is a 2xx status**, so `fetch(...).ok` is `true` and axios resolves. Branch on the status code explicitly.
This 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.
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:
- `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.
- `PROFILE_OVER_LIMIT`: a target account belongs to a profile beyond the plan's profile limit.
- `insufficient_permissions`: the post is not accessible to the caller.
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
'409':
description: Post is currently publishing
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'429':
description: |
Rate limit exceeded. Possible causes: API rate limit (requests per minute), velocity limit (25 posts/hour per account), or account cooldown (temporarily rate-limited due to repeated errors).
content:
application/json:
schema:
type: object
properties:
error: { type: string }
details:
type: object
/v1/posts/{postId}/unpublish:
post:
x-resource-group: "publishing"
operationId: unpublishPost
tags: [Posts]
summary: Unpublish post
description: |
Deletes a published post from the specified platform. The post record in Zernio is kept but its status is updated to cancelled.
Not supported on Instagram, TikTok, or Snapchat. Threaded posts delete all items. YouTube deletion is permanent.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform:
type: string
description: The platform to delete the post from
enum:
- threads
- facebook
- twitter
- linkedin
- youtube
- pinterest
- reddit
- bluesky
- googlebusiness
- telegram
example:
platform: "threads"
responses:
'200':
description: Post deleted from platform
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
example:
success: true
message: "Post deleted from threads successfully"
'400':
description: "Invalid request: platform not supported for deletion, post not on that platform, not published, no platform post ID, or no access token."
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Forbidden
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
'500':
description: Platform API deletion failed
/v1/posts/{postId}/edit:
post:
x-resource-group: "publishing"
operationId: editPost
tags: [Posts]
summary: Edit published post
description: |
Edit the text of an already-published post. Supported on X, Discord,
Facebook, Reddit, LinkedIn, Telegram, Pinterest, Google Business Profile, YouTube,
and Slack. When a post was published to several accounts on the same platform,
pass `accountId` to pick which account's copy to edit (the first entry is edited
otherwise). Each platform enforces its own rules:
**X**
- Connected X account must have an active X Premium subscription
- Must be within 1 hour of original publish time
- Maximum 5 edits per tweet (enforced by X)
- Threads cannot be edited, only single tweets
- X assigns a NEW post ID on edit, returned as `id`
**Discord**
- No time limit and no premium requirement
- The message ID is unchanged after the edit
**Facebook**
- Graph only permits editing a post that the same app created, so this works on
posts published through Zernio and is rejected for posts created in Meta
Business Suite / Composer or by another tool
- Media cannot be swapped, only the message text
- Reactions, comments, and shares are preserved. The post ID is unchanged
**Reddit**
- Self-posts only. A link post has no editable body and is rejected before the write
- Body only. Reddit exposes no API to edit a post title, ever
- The post ID is unchanged
**LinkedIn**
- Text only, no time limit. Media, polls, articles, and reshare targets cannot be
changed
- Works for member and organization posts published through this API. The post
keeps its ID and LinkedIn shows an "edited" marker
- Text is limited to 3,000 characters; mentions and hashtags are preserved
**Telegram**
- No time limit; messages published through Zernio are editable indefinitely
- Text posts: edits the message text (up to 4096 characters)
- Media posts: edits the caption only (up to 1024 characters). The media itself
cannot be swapped
- For albums, the caption shown on the album (its first message) is edited
- The message ID is unchanged
**Pinterest**
- Description only, maximum 800 characters. Media, link, and board cannot be
changed, and a pin title derived from the old content's first line at publish
stays as-is
- Pinterest's pin-update endpoint is currently in closed beta; until the app is
allowlisted by Pinterest, edits are rejected with a "beta feature not yet
enabled" error
- The pin ID is unchanged
**Google Business Profile**
- Post body (summary) text only. Call-to-action, event/offer fields, and media are
untouched
- No time limit and no edit limit. The post ID is unchanged
- The post must still exist on Google: a post deleted from the Business Profile
dashboard, or an event/offer post past its end date, returns a 404
**YouTube**
- `content` replaces the video description only. The title is unchanged, even if
it was originally derived from the content's first line at publish time
- Title, tags, thumbnail, and privacy edits belong to `POST /v1/posts/{postId}/update-metadata`
- No time window and no edit cap. The video ID is unchanged
**Slack**
- Text only, up to 4,000 characters. Media cannot be swapped, and media posts
whose share message reference never resolved cannot be edited
- No time limit unless workspace admins restrict message editing
- The message ID is unchanged
Media edits are not supported on any platform. The post record in Zernio is updated
with the new content and an edit-history entry.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform, content]
properties:
platform:
type: string
description: The platform to edit the post on.
enum: [twitter, discord, facebook, reddit, 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.
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.
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.
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Forbidden
'404': { $ref: '#/components/responses/NotFound' }
'500':
description: Platform API edit failed for a reason the platform did not classify.
/v1/posts/{postId}/update-metadata:
post:
x-resource-group: "publishing"
operationId: updatePostMetadata
tags: [Posts]
summary: Update post metadata
description: |
Updates metadata of a published video on the specified platform without re-uploading.
Currently only supported for YouTube. At least one updatable field is required.
Two modes:
1. Post-based (video published through Zernio): pass the Zernio postId in the URL and platform in the body.
2. Direct video ID (video uploaded outside Zernio, e.g. directly to YouTube): use _ as the postId,
and pass videoId + accountId + platform in the body. The accountId is the Zernio account ID
for the connected YouTube channel.
parameters:
- name: postId
in: path
required: true
schema: { type: string }
description: Zernio post ID, or "_" when using direct video ID mode
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform:
type: string
description: The platform to update metadata on
enum:
- youtube
videoId:
type: string
description: YouTube video ID (required for direct mode, ignored for post-based mode)
accountId:
type: string
description: Zernio 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.
Supports optional server-side pagination via page/limit params. When omitted, returns all accounts (backward-compatible).
page and limit must be supplied together; out-of-range page/limit values are rejected with 400 rather than silently clamped.
parameters:
- name: profileId
in: query
schema: { type: string }
description: Filter accounts by profile ID. Must be a valid ObjectId.
- name: platform
in: query
schema: { type: string }
description: Filter accounts by platform (e.g. "instagram", "twitter").
- name: status
in: query
schema:
type: string
enum: [connected, disconnected]
description: |
Filter accounts by connection status. `connected` returns healthy accounts; `disconnected` returns accounts that need reconnection (per the same reconnection check surfaced in the dashboard). Omit to return accounts in any status. When combined with page/limit, pagination totals reflect the filtered result set.
- name: includeOverLimit
in: query
required: false
schema:
type: boolean
default: false
description: When true, includes accounts from over-limit profiles.
- name: page
in: query
schema: { type: integer, minimum: 1 }
description: |
Page number (1-based). Must be provided together with limit to enable server-side pagination; sending only one of the two returns 400. Omit both for all accounts.
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100 }
description: |
Page size. Must be provided together with page; sending only one of the two returns 400.
responses:
'200':
description: Accounts (with optional pagination)
content:
application/json:
schema:
$ref: '#/components/schemas/AccountsListResponse'
examples:
example:
value:
accounts:
- _id: "64e1..."
platform: "twitter"
profileId:
_id: "64f0..."
name: "My Brand"
slug: "my-brand"
username: "@acme"
displayName: "Acme"
profileUrl: "https://x.com/acme"
isActive: true
hasAnalyticsAccess: false
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'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.
Requires analytics add-on subscription. Follower counts are refreshed once per day.
parameters:
- name: accountIds
in: query
schema: { type: string }
description: Comma-separated list of account IDs (optional, defaults to all user's accounts)
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID
- name: fromDate
in: query
schema: { type: string, format: date }
description: Start date in YYYY-MM-DD format (defaults to 30 days ago)
- name: toDate
in: query
schema: { type: string, format: date }
description: End date in YYYY-MM-DD format (defaults to today)
- name: granularity
in: query
schema: { type: string, enum: [daily, weekly, monthly], default: daily }
description: Data aggregation level
responses:
'200':
description: Follower stats
content:
application/json:
schema:
$ref: '#/components/schemas/FollowerStatsResponse'
examples:
example:
value:
accounts:
- _id: "64e1..."
platform: "twitter"
username: "@acme"
currentFollowers: 1250
growth: 50
growthPercentage: 4.17
dataPoints: 30
stats:
"64e1...":
- date: "2024-01-01"
followers: 1200
- date: "2024-01-02"
followers: 1250
dateRange:
from: "2024-01-01T00:00:00.000Z"
to: "2024-01-31T23:59:59.999Z"
granularity: "daily"
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
content:
application/json:
schema:
type: object
properties:
error: { type: string, example: Analytics add-on required }
message: { type: string, example: Follower stats tracking requires the Analytics add-on. Please upgrade to access this feature. }
requiresAddon: { type: boolean, example: true }
/v1/accounts/{accountId}:
put:
x-resource-group: "accounts"
operationId: updateAccount
tags: [Accounts]
summary: Update account
description: |
Updates a connected account's display name or username override.
For X accounts on usage-based billing, also accepts an `xCapabilities`
object to toggle background API operations that incur X API pass-through costs.
Both fields are opt-in (default `false`). When off, no analytics syncs or DM
polling are performed for that account, and no API call is metered for those
operations. Publishing and deleting posts are always available regardless of
these toggles. Setting `xCapabilities` on a non-X account returns 400.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
username: { type: string }
displayName: { type: string }
xCapabilities:
type: object
description: |
X only. Per-account opt-in toggles for background API
operations that incur X API pass-through costs. Each call is
billed at the X tier rate. Either field can be
sent independently; omitted fields are unchanged.
properties:
analytics:
type: boolean
description: |
Enable periodic analytics reads (impressions, likes, etc.)
for this X account. Each X API call is metered as
`posts_read` and billed pass-through (~$0.005/call at the
time of writing; the actual rate depends on X's pricing tier).
inbox:
type: boolean
description: |
Enable DM polling and inbox sync for this X account. DM
reads are metered as `dm_event_read` (~$0.010/call) and
DM sends as `dm_interaction_create` (~$0.015/call), both
billed pass-through. DM sends fire only on user-initiated
actions; reads/polling fire only when this flag is true.
example:
displayName: "Acme Corporation Official"
xCapabilities:
analytics: true
inbox: false
responses:
'200':
description: Updated
content:
application/json:
schema:
type: object
properties:
message: { type: string }
username: { type: string }
displayName: { type: string }
xCapabilities:
type: object
description: |
Echo of the resulting `xCapabilities` state, returned only
when the request body included an `xCapabilities` object.
properties:
analytics: { type: boolean }
inbox: { type: boolean }
example:
message: "Account updated successfully"
username: "@acmecorp"
displayName: "Acme Corporation Official"
xCapabilities:
analytics: true
inbox: false
'400': { description: Invalid request (e.g. xCapabilities on a non-X account) }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
patch:
x-resource-group: "accounts"
operationId: moveAccountToProfile
tags: [Accounts]
summary: Move account to another profile
description: |
Moves a connected account to a different profile owned by the same
user. The target profile must belong to the same user as the account.
For API keys restricted to specific profiles, BOTH the source account's
current profile AND the target profile must be in the key's allowed set.
Calls with a target profile outside the key's scope return 403.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId]
properties:
profileId:
type: string
description: Target profile ID (must be a valid ObjectId and owned by the same user as the account).
example:
profileId: "65f1a2b3c4d5e6f7a8b9c0d1"
responses:
'200':
description: Account moved
content:
application/json:
schema:
type: object
properties:
message: { type: string }
profileId: { type: string }
example:
message: "Account updated successfully"
profileId: "65f1a2b3c4d5e6f7a8b9c0d1"
'400': { description: Missing or invalid profileId }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: API key does not have access to the source account or target profile }
'404': { description: Account or target profile not found }
delete:
x-resource-group: "accounts"
operationId: deleteAccount
tags: [Accounts]
summary: Disconnect account
description: 'Disconnects and removes a connected 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.
Use it when the number has its own two-step verification PIN: the connect flows register
with a default PIN, Meta rejects that with error 133005, and the number then fails every
send with the misleading '(#200) You do not have the necessary permission to send messages'
while the account still shows as connected. The PIN is used for this call only and is not stored.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The WhatsApp account ID
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
pin:
type: string
pattern: '^\d{6}$'
description: 'The 6-digit two-step verification PIN set on the number. 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
Meta demoted to re-verification, this requests a new OTP from Meta. The code lands
on the customer's own handset, so verifying it is necessarily self-service; call
POST /v1/accounts/{accountId}/whatsapp/verify-code with the code once it arrives.
Rate-limited to one request per 10 minutes per account, and Meta enforces its own
cooldown on top of that.
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.
This only verifies the number with Meta; it does not register it on the Cloud API.
Call POST /v1/accounts/{accountId}/whatsapp/register afterward to complete activation.
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.
For WhatsApp accounts the response also includes `platformConnection`, a live probe of the
Meta link behind the channel (the same read as `GET /v1/whatsapp/number-info`). The OAuth
token can be perfectly valid while Meta refuses to serve the phone-number object (for
example after a phone-side coexistence disconnect), so `tokenStatus` alone is not a
liveness signal for WhatsApp. When the Meta link is dead, `platformConnection.status` is
`disconnected` and the overall `status` is `error`.
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
live from the platform API. This covers everything on the account, including posts that
were never created through Zernio.
Use it to obtain the platform's own post id, which the analytics endpoints take as input.
On YouTube the returned `id` is the video ID that `GET /v1/analytics/youtube/daily-views`,
`/video-retention` and `/demographics` expect as `videoId`, so this endpoint is what backs
a video picker in your own UI.
Not every field applies to every platform: `reactionCount` is Facebook and LinkedIn,
`shareCount` is platform dependent, `cid` is the Bluesky content id needed to reply, and
`subreddit` is Reddit only. Absent fields are omitted from the response.
The account's token is refreshed before the call when it has expired. When the refresh
cannot recover it, the response is a 401 with code `TOKEN_EXPIRED` and the account has to
be reconnected.
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' }
'503': { $ref: '#/components/responses/TemporarilyUnavailable' }
'502': { $ref: '#/components/responses/UpstreamFailure' }
/v1/accounts/{accountId}/follow-status/{userId}:
get:
operationId: getInstagramFollowStatus
tags: [Accounts]
x-platforms: ["instagram"]
summary: Check whether an Instagram user follows the account
description: |
Resolves the follow relationship between an Instagram user and the connected
account, plus their public profile counters.
`userId` is the Instagram-scoped id (IGSID) Meta gives you on a webhook:
`sender.id` on `message.received`, `comment.author.id` on `comment.received`.
**Meta only answers for people who have MESSAGED the account.** Commenting grants
no consent, so a commenter who has never DMed you is unresolvable - that is a
platform rule, not a limitation of this endpoint. When it cannot be resolved the
response is still `200` with `isFollower: null` and an `unavailableReason`, because
"unknown" is a normal state to branch on:
* `consent_required` - the user has never messaged this account.
* `dm_access_disabled` - the account owner turned off Instagram Direct API access.
* `not_messageable` - the id is not a messaging-scoped id.
* `error` - a transient Graph API failure.
To gate a comment automation on this, use the automation's `audience` rules instead
of calling this per comment - they run the same lookup only on comments that
actually match a keyword, and can ask the commenter to confirm with one tap.
Answers are cached briefly per (account, user). Pass `refresh=true` right after
asking someone to follow, so a follow from a moment ago is visible.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string }, description: Instagram account ID }
- { name: userId, in: path, required: true, schema: { type: string }, description: 'Instagram-scoped user id (IGSID) from a webhook payload' }
- { name: refresh, in: query, required: false, schema: { type: boolean }, description: Bypass the cache and re-query Meta }
responses:
'200':
description: Follow status (fields are null when Meta would not resolve it)
content:
application/json:
schema:
type: object
required: [userId, accountId, isFollower]
properties:
userId: { type: string }
accountId: { type: string }
isFollower:
type: [boolean, "null"]
description: 'The user follows this account. Null = unknown, never "no".'
isFollowedByAccount:
type: [boolean, "null"]
description: This account follows the user.
followerCount: { type: [integer, "null"] }
isVerified: { type: [boolean, "null"] }
username: { type: [string, "null"] }
name: { type: [string, "null"] }
unavailableReason:
type: [string, "null"]
enum: [consent_required, dm_access_disabled, not_messageable, error, null]
description: 'Why the follow relationship could not be resolved. Null when it was.'
example:
userId: "1312733120229708"
accountId: "6977a34e77637c5c857c816c"
isFollower: true
isFollowedByAccount: false
followerCount: 25
isVerified: false
username: "to.bias8262"
name: "Tobi Ma"
unavailableReason: null
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/accounts/{accountId}/tiktok/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
user has authorized and that still hold a live token.
Requires a session or a full-access API key. A profile-scoped API key, a
restricted (zrk_) API key, or an OAuth access token is rejected with 403: an
app must not be able to enumerate its sibling authorizations, and connected-app
management is admin-plane.
responses:
'200':
description: Connected apps
content:
application/json:
schema:
type: object
properties:
connectedApps:
type: array
items: { $ref: '#/components/schemas/ConnectedApp' }
example:
connectedApps:
- clientId: "late_cid_3f9c1a7b2d4e6f80a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718"
clientName: "Claude"
redirectHost: "claude.ai"
scopes: ["posts:read", "posts:write", "accounts:read"]
authorizedAt: "2026-07-02T09:14:00Z"
lastUsedAt: "2026-07-30T18:02:11Z"
tokenCount: 2
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/ResourceGroupForbidden' }
/v1/me/connected-apps/{clientId}:
delete:
x-resource-group: "admin-plane"
operationId: revokeConnectedApp
tags: [Connected Apps]
summary: Revoke connected app
description: |
Ends an app's access: invalidates the client's pending authorization codes and
revokes every live token it holds for the authenticated user. Takes effect on
the app's next request.
Idempotent while the authorization is still on record: revoking an app that
was already revoked returns 200 with `revokedTokens: 0`.
Requires a session or a full-access API key. A profile-scoped API key, a
restricted (zrk_) API key, or an OAuth access token is rejected with 403.
parameters:
- name: clientId
in: path
required: true
description: 'OAuth client id, as returned by GET /v1/me/connected-apps.'
schema: { type: string, minLength: 1, maxLength: 200 }
responses:
'200':
description: Revoked
content:
application/json:
schema:
type: object
properties:
revoked: { type: boolean }
clientId: { type: string }
revokedTokens: { type: integer, description: 'Access and refresh tokens revoked by this call.' }
invalidatedCodes: { type: integer, description: 'Pending authorization codes invalidated by this call.' }
example:
revoked: true
clientId: "late_cid_3f9c1a7b2d4e6f80a1b2c3d4e5f60718293a4b5c6d7e8f90a1b2c3d4e5f60718"
revokedTokens: 2
invalidatedCodes: 0
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/ResourceGroupForbidden' }
'404':
description: 'The authenticated user has never authorized this client. Error code: oauth_client_not_found.'
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/invite/tokens:
post:
x-resource-group: "admin-plane"
operationId: createInviteToken
tags: [Invites]
summary: Create invite token
description: |
Generate a secure invite link to grant team members access to your profiles.
Invites expire after 7 days and are single-use.
Returns 403 when a requested profile is not found or not owned, or when
called with a restricted (zrk_) API key: invite management is admin-plane.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [scope]
properties:
scope:
type: string
enum: [all, profiles]
description: "'all' grants access to all profiles, 'profiles' restricts to specific profiles"
profileIds:
type: array
items: { type: string }
description: Required if scope is 'profiles'. Array of profile IDs to grant access to.
role:
type: string
enum: [admin, member, billing_admin, viewer]
default: member
description: Org role granted to the invitee. Defaults to 'member'. 'admin' can manage the team (invite/remove members, change roles and access) and billing, but not ownership transfer or account deletion. 'billing_admin' (displayed as Billing Manager) manages billing only. 'viewer' creates a read-only member who can view everything in their profile scope but cannot perform any content mutation (publish, edit, delete, connect accounts).
readOnly:
type: boolean
deprecated: true
description: Deprecated. Use role 'viewer' instead. When true, the invite is created with role 'viewer'. Cannot be combined with role 'billing_admin' or 'admin'.
example:
scope: "profiles"
profileIds:
- "64f0a1b2c3d4e5f6a7b8c9d0"
- "64f0a1b2c3d4e5f6a7b8c9d1"
role: "member"
responses:
'201':
description: Invite token created
content:
application/json:
schema:
type: object
properties:
token: { type: string }
scope: { type: string }
invitedProfileIds:
type: array
items: { type: string }
expiresAt: { type: string, format: date-time }
inviteUrl: { type: string, format: uri }
example:
token: "inv_abc123def456ghi789"
scope: "profiles"
invitedProfileIds:
- "64f0a1b2c3d4e5f6a7b8c9d0"
- "64f0a1b2c3d4e5f6a7b8c9d1"
expiresAt: "2024-11-08T10:30:00Z"
inviteUrl: "https://zernio.com/invite/inv_abc123def456ghi789"
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/ResourceGroupForbidden' }
/v1/connect/{platform}:
get:
x-resource-group: "accounts"
operationId: getConnectUrl
tags: [Connect]
summary: Get OAuth connect URL
description: |
Initiate an OAuth connection flow. Returns an authUrl to redirect the user to.
Standard flow: Zernio hosts the selection UI, then redirects to your redirect_url. Headless mode (headless=true): user is redirected to your redirect_url with OAuth data for custom UI. Use the platform-specific selection endpoints to complete.
TikTok: every connection now goes through the TikTok for Business app. One TikTok account per
profile, so connecting on a profile that already holds one replaces it. Reconnecting the SAME
account keeps it and all of its history; authorizing a DIFFERENT TikTok account takes the slot
over and permanently deletes the previous account's analytics, inbox and DM history. The two
are told apart by the `@handle` stored at the last connect, so an account whose handle has
been renamed on TikTok since then reads as a different account. An authorization that leaves
out a permission the connected account needs changes nothing at all and comes back as
`missing_tiktok_permissions`; connect again and accept every permission on TikTok's screen.
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.
On failure, the browser is sent to the same redirect_url with `error` and `platform` appended.
`error` and `platform` are always present. `error_message`, `is_user_fixable`, `reason` and
`dashboard_url` are conditional and must be treated as optional.
This list is NOT exhaustive and new values may be added at any time. Treat an unrecognized
value as a generic failure rather than matching it exhaustively. Existing values are not
renamed or removed without notice.
OAuth and callback:
oauth_denied, invalid_callback, invalid_state, unsupported_platform, connection_failed,
internal_error, token_exchange_failed, byok_config_error, personal_account_not_supported,
missing_google_permissions, missing_tiktok_permissions, platform_requires_destination,
reconnect_account_mismatch, invalid_request
Access and limits:
profile_not_found, invalid_profile_id, access_denied, account_limit_exceeded,
profile_limit_exceeded, payment_required
Destination selection:
no_facebook_pages, facebook_pages_error, no_google_locations, google_locations_error,
google_permission_denied, no_snapchat_public_profiles, snapchat_profiles_error,
discord_no_guild, slack_no_team
WhatsApp:
whatsapp_error, one_whatsapp_per_profile, whatsapp_number_already_connected,
whatsapp_number_pinned_to_profile, connection_cancelled
Google Ads (platform=googleads):
google_ads_auth_failed, google_ads_invalid_state, google_ads_config_error,
google_ads_token_failed, google_ads_quota_exhausted, google_ads_callback_error
TikTok Ads (platform=tiktokads):
tiktok_ads_auth_failed, tiktok_ads_invalid_state, tiktok_ads_access_denied,
tiktok_ads_config_error, tiktok_ads_token_failed, tiktok_ads_account_not_found,
tiktok_ads_callback_error
X Ads (platform=xads):
x_ads_denied, x_ads_auth_failed, x_ads_config_error, x_ads_account_not_found,
x_ads_state_error, x_ads_token_failed, x_ads_token_missing, x_ads_callback_error
Shopify (platform=shopify):
shopify_auth_failed, shopify_config_error, shopify_invalid_state, shopify_invalid_hmac,
shopify_invalid_shop, shopify_missing_scopes, shopify_callback_error
1. On this endpoint every upstream OAuth error is collapsed into `oauth_denied`. The
provider's own value (for example Meta's `access_denied`) is not forwarded. The dedicated
ads flows below are different: they use their own denial slugs and `google_ads_auth_failed`
and `tiktok_ads_auth_failed` may carry the provider's raw error string in `error_message`.
2. On the tiktok and twitter ads flows `platform` carries the ads platform id
(`tiktokads`, `xads`), not the value used in the request path. The googleads and shopify
flows report `googleads` and `shopify`.
3. `missing_tiktok_permissions` means the TikTok authorization left out a permission the
already-connected account needs, so nothing was changed and it keeps working as before.
It is user-fixable: connect again and accept every permission on TikTok's screen.
- name: headless
in: query
schema: { type: boolean, default: false }
description: When true, the user is redirected to your redirect_url with raw OAuth data (code, state) instead of Zernio's default account selection UI. Use this to build a custom connect experience.
- name: loginMethod
in: query
schema: { type: string, enum: [instagram_login, facebook_login], default: instagram_login }
description: |
Instagram only. Which of the two Instagram connection methods to use. Ignored for every other platform.
`instagram_login` (the default, and what you get if you omit this): the Instagram Login dialog. The user authorizes their Instagram professional account directly, no Facebook Page required.
`facebook_login`: the Facebook Login dialog, i.e. "Instagram API with Facebook Login". The user authorizes a Facebook Page that has a linked Instagram professional account, and every API call for that account then runs through the Page. Use this when the customer manages Instagram through a Page and expects the Facebook consent screen. Because the user has to pick which Page to connect, the callback continues at the account-selection step, `/v1/connect/instagram/select-account`.
`facebook_login` supports `headless=true` like the other selection platforms: the callback redirects to your `redirect_url` with `profileId`, `tempToken`, `platform=instagram`, `step=select_account` and `connect_token`, which you pass into the select-account endpoints to finish. The default `instagram_login` has no selection step, so it connects the account directly.
- 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.
If omitted, the connection defaults to coexistence (same as `business_app` below), preserving existing behavior for numbers already on the WhatsApp Business app.
`api`: standard Embedded Signup, showing Meta's WABA/number picker. Use this to connect a phone number already on Cloud API elsewhere.
`business_app`: coexistence, i.e. 'Connect existing WhatsApp Business app' (a number shared between Cloud API and the consumer WhatsApp Business app).
- name: signup
in: query
schema: { type: string, enum: [hosted] }
description: |
WhatsApp only. Rejected with 400 `INVALID_FIELD_VALUE` on any other platform.
`hosted`: `authUrl` points at a Zernio-hosted page on zernio.com instead of Meta's OAuth dialog, and
the response carries `authUrl` only (no `state`). That page opens Meta's Embedded Signup popup itself,
so it learns which WhatsApp Business Account and number the user picked inside the popup and connects
exactly that one. Use it when your users' Facebook logins manage several WhatsApp accounts: on the
default redirect flow Meta only returns an authorization code, so when that login can see more than
one number the user lands on Zernio's number picker and has to choose again. Nothing to embed on your
side and no domain setup: send the user to `authUrl`, and they come back to `redirect_url` with the
same params as the redirect flow. Success: `connected=whatsapp`, `profileId`, `accountId`, `username`
(plus `connect_token` for API-key callers). Failure: `error` and `platform=whatsapp`, with the same
values and extras as the redirect flow (`one_whatsapp_per_profile`, `whatsapp_number_already_connected`
and `whatsapp_number_pinned_to_profile` with `is_user_fixable=true`; `payment_required` with `reason`
and `dashboard_url`; `whatsapp_error` with `error_message` when Meta reported one), plus two of its own:
`connection_cancelled` when the popup was closed before finishing (`error_message` carries Meta's last
reported step or error when there is one) and `session_expired` when the user took longer than the
60 minute window the hosted page is valid for; restart the flow with a new call in that case.
`onboarding` is carried through and a pre-verified Zernio-provisioned number is attached like on the
redirect flow. `headless` has no effect here because there is no selection step left to hand you.
When a previously disconnected account for this profile can simply be re-enabled, this endpoint
re-enables it and returns the account directly instead of a URL, exactly like the redirect flow.
The page shows the same guidance as the Zernio dashboard: a pre-verified Zernio-provisioned number is
called out by name ("choose it in Meta's list, no code will be asked"), coexistence and standard signups
get their explainer video, and a step-by-step follow-along checklist stays visible while Meta's popup is
open. Skin it with `brandName`, `primaryColor` and `language` below.
- 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.
Facebook, 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`.
parameters:
- name: platform
in: path
required: true
description: |
Social platform to complete the connect for. Discord, Slack and Telegram are absent because they are
served by their own dedicated routes, documented separately.
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.
**Meta business login (opt-in).** Set `loginMode=business` for `facebook` or
`instagram` to use Facebook Login for Business and a Business Integration System User
token. No posting account is created or required. This mode always returns an authUrl;
it returns 503 when the server has no META_ADS_CONFIG_ID. Complete the dialog in a
browser. The callback creates or reconnects only the metaads account, preserving its
ID, history and scopedAdAccountIds. Non-empty successful subscription results replace
subscribedAdAccountIds to remove stale grants; an empty result leaves routing unchanged. A reconnect must grant
every previously scoped ad account (or every previous grant for an unscoped connection).
Missing or unverifiable grants return 409 before changing the account.
Pass `pageId` to select a granted Page for creatives and lead forms. API integrations
otherwise reuse the previous Page or sole granted Page. Multiple Pages without a selection
return 400 with available Page IDs for API integrations; restart with pageId.
Dashboard session logins use the sole current grant automatically or open the existing
Facebook Page picker for several grants, including reconnects. Selection completes
the Meta Ads connection. With no Pages granted the callback returns
400 with instructions to connect again and grant a Page.
Success redirects with connected=metaads, profileId and accountId.
Business login reports metadata.tokenType=system-user in GET /v1/accounts. An absent
Meta expires_in leaves tokenExpiresAt absent; no personal-token re-exchange occurs.
Subsequent classic requests can change the ad-account scope using the business token;
force=true requires loginMode=business to reconnect that connection.
**Same-token platforms (facebook, instagram, linkedin, pinterest).** The ads SocialAccount
(metaads, linkedinads, pinterestads) reuses the OAuth token of the parent posting account,
but only when an active parent exists and, for facebook and instagram, its stored token
carries ads_management and ads_read (linkedin and pinterest need no extra scope). In that
case no extra OAuth happens and the response is alreadyConnected: true.
When no such parent exists, or the scopes are missing, the endpoint returns an authUrl and a
full OAuth round trip is required. When a parent exists but carries no token usable for ad
accounts, the call fails with 400 RECONNECT_REQUIRED. Independently of the branch, the call
can return 403 ADS_ADDON_REQUIRED without the ads add-on and 402 PAYMENT_REQUIRED when the
billing gate is closed.
Meta Ads prerequisite: connecting Meta Ads (via facebook or instagram) requires a Facebook
Page. Not because the ad account is read through a Page, but because both parent posting
accounts are: the facebook flow only offers Pages you manage, and the instagram flow with
loginMethod=facebook_login only offers Instagram accounts linked to one of those Pages.
Without a Page there is no parent account to inherit a token from. A user who manages no
Facebook Page cannot complete this connection, and the facebook flow ends with
error=no_facebook_pages.
**Separate-token platforms (tiktok, twitter).** Starts the platform-specific marketing API
OAuth flow and creates an ads SocialAccount (tiktokads, xads) with its own token. If the ads
account already exists, returns alreadyConnected: true.
- tiktok: accountId is OPTIONAL. With accountId, the new tiktokads account links to that posting account (parentAccountId set), 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).
- twitter (X Ads): accountId is REQUIRED. There's no ads-only mode, because tweets need to be authored by a real X user.
**Standalone platforms (googleads).** Starts the Google Ads OAuth flow and creates a
standalone ads SocialAccount (googleads) with no parent. If the account already exists,
returns alreadyConnected: true.
Ads accounts appear as regular SocialAccount documents with ads platform values (e.g., metaads, tiktokads) in GET /v1/accounts.
parameters:
- name: 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.
In classic mode, `instagram` requires an Instagram account connected with loginMethod=facebook_login whose
token carries ads_management and ads_read. With an account connected through the default
instagram_login flow no ads account can be created; do not use this value for those accounts.
- name: profileId
in: query
required: true
schema: { type: string }
description: Your Zernio profile ID
- name: accountId
in: query
schema: { type: string }
description: |
Existing SocialAccount ID. Required for `twitter` (X Ads). Optional for `tiktok`:
omit to enter ads-only mode (no TikTok posting account linked; ad creation uses
a Brand Identity instead of a TT_USER). Ignored for same-token (`facebook`,
`instagram`, `linkedin`, `pinterest`) and standalone (`googleads`) platforms.
- name: redirect_url
in: query
schema: { type: string, format: uri }
description: |
Custom URL the browser is sent to once the OAuth flow finishes. Honored on
every ads platform, including the separate-token (`tiktok`, `twitter`) and
standalone (`googleads`) flows. 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 success
`tiktok`, `twitter` and `googleads` land on the URL unchanged, while the
same-token platforms (`facebook`, `instagram`, `linkedin`, `pinterest`)
append `connected`, `profileId`, `accountId`, `username` and, on API-key
calls, `connect_token`. On failure the same error contract applies as on
GET /v1/connect/{platform}: `error` and `platform` are always appended,
other params are optional, and the value list there is not exhaustive.
On the tiktok, twitter and googleads flows `platform` carries
the ads platform id (`tiktokads`, `xads`, `googleads`), not the value
used in the request path. When omitted, the browser lands on
the Zernio dashboard.
- name: headless
in: query
schema: { type: boolean, default: false }
description: Enable headless mode (same-token platforms only)
- name: force
in: query
schema: { type: boolean, default: false }
description: |
Force a fresh OAuth even when an account already exists. Normally the
endpoint returns `alreadyConnected: true` whenever a connected account
is found, keying off its active state rather than token liveness.
Set `force=true` to bypass that and always receivean `authUrl`.
Completing the returned OAuth refreshes the stored token
on the existing posting and ads accounts in place.
- name: adAccountId
in: query
schema: { type: string }
description: |
Scope ad sync to a single platform ad account. Without this param,
sync covers every ad account the connected token can see. Business-login reconnects
preserve the existing scope; supplied IDs are checked against the new grant. To change
that scope after migration, call this endpoint with the IDs and omit loginMode. Supported
on `facebook`/`instagram` (Meta, `act_<digits>`), `linkedin` (bare
numeric sponsored-account id), `googleads` (bare customer id digits)
and `twitter` (X Ads, base36 account id). `tiktok` scopes advertisers
at OAuth and `pinterest` has no ads discovery, so both ignore it.
Meta ids are additionally validated against the connected token;
unreachable IDs return 400. Setting a scope also removes already
synced ads from de-scoped ad accounts. For multiple accounts use
`adAccountIds` instead.
example: act_1330190928038136
- name: adAccountIds
in: query
style: form
explode: true
schema:
type: array
items: { type: string }
description: |
Scope ad sync to multiple platform ad accounts (same platform
support and id shapes as `adAccountId`). Repeat the param
(`?adAccountIds=act_1&adAccountIds=act_2`) or comma-separate
(`?adAccountIds=act_1,act_2`). Persisted server-side; latest call
wins, and de-scoped ad accounts have their synced ads removed.
Omitting both `adAccountId` and `adAccountIds` keeps any previously
persisted scope unchanged.
security:
- bearerAuth: []
responses:
'200':
description: Either an OAuth URL to redirect to, or confirmation that ads are already connected
content:
application/json:
schema:
oneOf:
- type: object
description: Ads already connected (no OAuth needed)
properties:
alreadyConnected: { type: boolean, example: true }
accountId: { type: string }
platform: { type: string }
username: { type: string }
displayName: { type: string }
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
`adAccountId` / `adAccountIds`. Omitted when no scope is set.
example: ["act_1330190928038136"]
- type: object
description: OAuth URL to redirect user to
properties:
authUrl: { type: string, format: uri }
state: { type: string }
examples:
alreadyConnected:
summary: Same-token platform (Meta) with existing account
value:
alreadyConnected: true
accountId: "664a1b2c3d4e5f6789012345"
platform: "instagram"
username: "@mybrand"
displayName: "My Brand"
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
platform: the connected account does not publish social posts, it powers
the Blogs API (`/v1/accounts/{accountId}/blogs`). Returns an `authUrl`
to redirect the merchant to; after they approve the install, Shopify
redirects their browser to Zernio's callback, the account is created on
the profile (platform `shopify`), and the browser is redirected to
`redirect_url` (or the Zernio dashboard when omitted). Requested scopes
are `read_content` and `write_content` (content only; no customer or
order data). Connecting the same profile to a store again refreshes the
stored token in place.
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
Admin API access token of a custom app the merchant created in their
own Shopify admin (Settings → Apps and sales channels → Develop apps,
with the `read_content`/`write_content` scopes). Use this when the
one-click OAuth connect is unavailable or when your users prefer not
to install a third-party app on their store. The token is validated
against the store before anything is saved; custom-app tokens do not
expire. Connecting the same profile to a store again replaces the
stored token in place.
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
`tiktokads` SocialAccount. TikTok requires every ad to carry an
`identity_id + identity_type` pair. The Brand Identity is the
CUSTOMIZED_USER alternative to attributing ads to a real @username
(TT_USER). This route uploads the supplied image to TikTok, creates
the identity via `/v2/identity/create/`, and caches the resulting
`identity_id` on the account so subsequent `POST /v1/ads/create`
calls can opt into it via `identityType: 'CUSTOMIZED_USER'`.
Configurable on every `tiktokads` account, including linked-mode ones
(those with a posting account on the same profile). Configuration is
idempotent and harmless when posting is also connected: the default
ad-create path still prefers TT_USER, and CUSTOMIZED_USER is only used
per-ad when the caller explicitly opts in.
TikTok identities are immutable post-creation. Re-saving creates a new
identity on TikTok and swaps the cached id; the old identity stays
orphaned on TikTok's side (harmless, no billing impact).
Alternative: pass `brandIdentity` directly on `POST /v1/ads/create` to
configure on first ad creation in a single round-trip.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, displayName, imageUrl]
properties:
accountId:
type: string
description: SocialAccount ID of the `tiktokads` account.
displayName:
type: string
minLength: 1
maxLength: 40
description: Brand name shown above the ad on TikTok.
imageUrl:
type: string
format: uri
description: Public URL of a square brand image (≥98×98 px, JPG/PNG, max 5 MB). Used as the brand avatar on the ad.
responses:
'200':
description: Brand identity configured (or updated)
content:
application/json:
schema:
type: object
properties:
success: { type: boolean, example: true }
identityId: { type: string, description: "The TikTok-assigned identity_id, cached on the account." }
displayName: { type: string }
'400':
description: "Missing fields, invalid JSON body, invalid accountId format, invalid lengths, or no advertiser found on the account"
'401': { $ref: '#/components/responses/Unauthorized' }
'404':
description: TikTok Ads account not found
'500':
description: Unexpected server error while caching the identity
'502':
description: 'TikTok rejected the image upload or the identity creation (type: platform_error; an upstream 4xx status is forwarded instead of 502)'
/v1/connect/facebook/select-page:
get:
x-resource-group: "accounts"
operationId: listFacebookPages
tags: [Connect]
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.' }
'403': { description: 'The caller is not the initiating user or no longer has profile access.' }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Failed to fetch pages (e.g., invalid token, insufficient permissions)
content:
application/json:
schema:
type: object
properties:
error: { type: string }
post:
x-resource-group: "accounts"
operationId: selectFacebookPage
tags: [Connect]
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
was initiated as a `force=true` token-recovery re-auth
(`GET /v1/connect/{platform}/ads`), but the grant landed on a different
Facebook user or page than the connected account. The existing account
is left untouched.
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string, enum: [RECONNECT_ACCOUNT_MISMATCH] }
'500':
description: Failed to save Facebook connection
/v1/connect/instagram/select-account:
get:
operationId: listInstagramPages
tags: [Connect]
summary: List Pages with a linked Instagram account
description: |
Completes the `loginMethod=facebook_login` Instagram flow, i.e. "Instagram API with Facebook Login".
After the user authorizes on Facebook, extract `tempToken` from the redirect params (headless mode adds `step=select_account`) and pass it here to list the Facebook Pages they manage. Only Pages that have a linked Instagram professional account are returned, so an empty array means the user has no eligible Page. Use the X-Connect-Token header if connecting via API key.
Not used by the default `instagram_login` flow, which creates the account without a selection step.
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
description: Profile ID from your connection flow
- name: tempToken
in: query
required: true
schema: { type: string }
description: Long-lived Facebook user access token from the OAuth callback redirect
security:
- bearerAuth: []
- connectToken: []
responses:
'200':
description: Facebook Pages that have a linked Instagram professional account
content:
application/json:
schema:
type: object
properties:
pages:
type: array
items:
type: object
properties:
id: { type: string, description: Facebook Page ID }
name: { type: string, description: Page name }
access_token: { type: string, description: Page-specific access token }
instagram_business_account:
type: object
description: The Instagram professional account linked to this Page
properties:
id: { type: string, description: Instagram Business Account ID }
username: { type: string }
profile_picture_url: { type: string }
example:
pages:
- id: "811889972008357"
name: "My Brand Page"
access_token: "EAAxxxxx..."
instagram_business_account:
id: "17841400649984407"
username: "mybrand"
profile_picture_url: "https://..."
'400': { description: 'Missing required parameters (profileId or tempToken)' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: User does not have access to the specified profile }
post:
operationId: selectInstagramAccount
tags: [Connect]
summary: Select the Page whose Instagram account to connect
description: |
Saves the selected Page as an Instagram account connected via Facebook Login. The Page access token becomes the account's access token, so every Instagram call for it runs against the Facebook Graph host.
One Instagram account per profile: if the profile already has an Instagram account, this replaces it, and picking a different Instagram identity purges the previous account's conversations, external posts and stats.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, pageId, tempToken]
properties:
profileId:
type: string
description: Profile ID from your connection flow
pageId:
type: string
description: 'The Facebook Page ID selected by the user, from GET /v1/connect/instagram/select-account'
tempToken:
type: string
description: Long-lived Facebook user access token from the OAuth callback redirect
redirect_url:
type: string
format: uri
description: Optional custom redirect URL to return to after selection
example:
profileId: "507f1f77bcf86cd799439011"
pageId: "811889972008357"
tempToken: "EAAxxxxx..."
redirect_url: "https://yourdomain.com/integrations/callback"
security:
- bearerAuth: []
- connectToken: []
responses:
'200':
description: Instagram account connected
content:
application/json:
schema:
type: object
properties:
message: { type: string }
redirect_url:
type: string
description: Redirect URL if a custom redirect_url was provided
account:
type: object
properties:
accountId: { type: string, description: ID of the created SocialAccount }
platform: { type: string, enum: [instagram] }
username: { type: string }
displayName: { type: string, description: Name of the Facebook Page backing this account }
profilePicture: { type: string }
isActive: { type: boolean }
loginMethod: { type: string, enum: [facebook_login] }
example:
message: "Instagram (via Facebook) connected"
account:
accountId: "64e1f0a9e2b5af0012ab34cd"
platform: "instagram"
username: "mybrand"
displayName: "My Brand Page"
profilePicture: "https://..."
isActive: true
loginMethod: "facebook_login"
'400':
description: 'Missing required fields, or the selected Page has no linked Instagram professional account'
'401': { $ref: '#/components/responses/Unauthorized' }
'402': { $ref: '#/components/responses/PaymentRequired' }
'403': { description: User does not have access to the specified profile }
'404': { description: Selected page not found among the pages this token can manage }
/v1/connect/googlebusiness/locations:
get:
x-resource-group: "accounts"
operationId: listGoogleBusinessLocations
tags: [Connect]
summary: List 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.
parameters:
- name: profileId
in: query
required: false
schema: { type: string }
description: Profile ID from your connection flow. Required for auth validation when provided.
- name: pendingDataToken
in: query
required: false
schema: { type: string }
description: Token from the OAuth callback redirect. Preferred over tempToken because it preserves server-side token storage. One of pendingDataToken or tempToken is required.
- name: tempToken
in: query
required: false
schema: { type: string }
description: Legacy. Direct Google access token. Use pendingDataToken instead when available.
- name: search
in: query
required: false
schema: { type: string }
description: >
Free-text search on the business name, applied server-side by Google.
Use this for accounts that own many locations (the response is bounded,
see hasMore) so the user can find a specific location without loading
the full list.
- name: filter
in: query
required: false
schema: { type: string }
description: >
Raw Google Business Information API filter expression (advanced;
takes precedence over search). Supports fields such as title, storeCode,
storefront_address.postal_code, labels and categories, e.g.
storeCode="LH279411". See Google's "Work with location data" guide.
security:
- bearerAuth: []
- connectToken: []
responses:
'200':
description: List of Google Business 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.
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.
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.
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' }
'503': { $ref: '#/components/responses/TemporarilyUnavailable' }
'502': { $ref: '#/components/responses/UpstreamFailure' }
/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.
`title` and `metadata` are always included in the response so the `location` summary block can be populated, even if you omit them here.
Note: `location` is a derived response field, not a Google readMask value, passing it returns 400.
security:
- bearerAuth: []
responses:
'200':
description: Location details fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
location:
type: [object, "null"]
description: |
Compact public-facing summary derived from Google's `metadata`. Useful
for surfacing the "leave a review" URL (e.g. behind a QR code) without
parsing the raw block. Always populated regardless of readMask.
For unverified or new locations Google omits placeId/reviewUrl/mapsUri,
so those return as null and `isVerified` is false.
properties:
name: { type: [string, "null"], description: Business name as set in 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
includes a value that is not a valid Google Business Information
field (e.g. `location`, which is a response-only derived field).
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
example:
error: "Request contains an invalid argument."
code: "gbp_bad_request"
'401':
description: Unauthorized or token expired
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404': { $ref: '#/components/responses/NotFound' }
put:
x-resource-group: "accounts"
operationId: updateGoogleBusinessLocationDetails
tags: [GMB Location Details]
summary: Update location details
description: |
Updates Google Business Profile location details. The updateMask field is required and specifies which fields to update.
This endpoint proxies Google's Business Information API locations.patch, so any valid updateMask field is supported.
Common fields: regularHours, specialHours, profile.description, websiteUri, phoneNumbers, categories, serviceItems.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: The Zernio account ID (from /v1/accounts)
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [updateMask]
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.
Returns photo URLs, descriptions, categories, and metadata.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
- name: pageSize
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 100 }
description: Number of items to return (max 100)
- name: pageToken
in: query
schema: { type: string }
description: Pagination token from previous response
security:
- bearerAuth: []
responses:
'200':
description: Media items fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
mediaItems:
type: array
items:
type: object
properties:
name: { type: string, description: Resource name }
mediaFormat: { type: string, enum: [PHOTO, VIDEO] }
sourceUrl: { type: string }
googleUrl: { type: string, description: Google-hosted URL }
thumbnailUrl: { type: string }
description: { type: string }
createTime: { type: string, format: date-time }
locationAssociation:
type: object
properties:
category: { type: string }
nextPageToken: { type: string }
totalMediaItemsCount: { type: integer }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
post:
x-resource-group: "accounts"
operationId: createGoogleBusinessMedia
tags: [GMB Media]
summary: Upload photo
description: |
Creates a media item (photo) for a location from a publicly accessible URL.
Categories determine where the photo appears: CATEGORY_UNSPECIFIED, COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, PRODUCT, FOOD_AND_DRINK, MENU, COMMON_AREA, ROOMS, TEAMS, AT_WORK, ADDITIONAL.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [sourceUrl]
properties:
sourceUrl: { type: string, format: uri, description: Publicly accessible image URL }
mediaFormat: { type: string, enum: [PHOTO, VIDEO], default: PHOTO }
description: { type: string, minLength: 1, description: Photo description }
category:
type: string
enum: [CATEGORY_UNSPECIFIED, COVER, PROFILE, LOGO, EXTERIOR, INTERIOR, PRODUCT, FOOD_AND_DRINK, MENU, COMMON_AREA, ROOMS, TEAMS, AT_WORK, ADDITIONAL]
description: Where the photo appears on the listing
example:
sourceUrl: "https://example.com/photos/restaurant-interior.jpg"
description: "Dining area with outdoor seating"
category: "INTERIOR"
responses:
'200':
description: Media created successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
name: { type: string }
mediaFormat: { type: string }
googleUrl: { type: string }
'400':
description: Invalid request or unsupported media format
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
delete:
x-resource-group: "accounts"
operationId: deleteGoogleBusinessMedia
tags: [GMB Media]
summary: Delete photo
description: Deletes a photo or media item from a 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
a location or business category. Use this endpoint to discover valid attribute names,
value types, and allowed enum values before reading or writing via gmb-attributes.
Two mutually exclusive query modes:
**Location mode**: pass `locationId` (or rely on the account's stored `selectedLocationId`).
Google returns attributes valid for that specific location.
**Category mode**: pass `categoryName` (must start with `categories/`) and `regionCode`.
Google returns attributes valid for that category across the given region.
`languageCode` is optional in category mode.
Both modes support `pageSize` and `pageToken` for pagination.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: >
Google Business Profile location ID (e.g. "6257659026299438786"). If omitted, uses the account's
stored selectedLocationId. Mutually exclusive with categoryName.
- name: categoryName
in: query
schema: { type: string }
description: >
Category resource name, must start with "categories/" (e.g.
"categories/gcid:plumber"). Required together with regionCode. Mutually
exclusive with locationId.
- name: regionCode
in: query
schema: { type: string }
description: >
BCP-47 region code (e.g. "US", "ES"). Required when categoryName is provided.
- name: languageCode
in: query
schema: { type: string }
description: >
BCP-47 language code for display names (e.g. "en", "es"). Optional when
categoryName is provided. Omitted from the Google call when not supplied.
- name: pageSize
in: query
schema: { type: integer, minimum: 1, maximum: 200 }
description: Maximum number of attribute metadata items to return. Google defaults to 200.
- name: pageToken
in: query
schema: { type: string }
description: Pagination token from a previous response's nextPageToken field.
security:
- bearerAuth: []
responses:
'200':
description: Attribute metadata fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId:
type: string
description: Only present in location mode.
attributeMetadata:
type: array
items:
type: object
properties:
parent:
type: string
description: Resource name of the attribute (e.g. "attributes/has_delivery").
valueType:
type: string
description: Value type (e.g. BOOL, ENUM, URL, REPEATED_ENUM).
displayName:
type: string
description: Localized human-readable attribute name.
groupDisplayName:
type: string
description: Display name of the attribute group.
repeatable:
type: boolean
description: True if multiple values can be set simultaneously.
deprecated:
type: boolean
description: True if this attribute should no longer be used.
valueMetadata:
type: array
description: Possible enum values (for ENUM / REPEATED_ENUM types).
items:
type: object
properties:
value: { type: string }
displayName: { type: string }
nextPageToken:
type: string
description: Present when additional pages of results are available.
example:
success: true
accountId: "64f1a2b3c4d5e6f7a8b9c0d1"
locationId: "6257659026299438786"
attributeMetadata:
- parent: "attributes/has_delivery"
valueType: "BOOL"
displayName: "Delivery"
groupDisplayName: "Service options"
- parent: "attributes/pay_credit_card_types_accepted"
valueType: "REPEATED_ENUM"
displayName: "Credit cards accepted"
valueMetadata:
- value: "visa"
displayName: "Visa"
- value: "mastercard"
displayName: "Mastercard"
'400':
description: Invalid request (mixed modes, missing required params, wrong platform, or Google returned 4xx)
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Access token is invalid or revoked. Reconnect the Google Business Profile account.
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'404':
description: Account not found
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-attributes:
get:
x-resource-group: "accounts"
operationId: getGoogleBusinessAttributes
tags: [GMB Attributes]
summary: Get attributes
description: Returns 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.).
The attributeMask specifies which attributes to update (comma-separated).
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [attributes, attributeMask]
properties:
attributes:
type: array
items:
type: object
required: [name]
properties:
name: { type: string, minLength: 1 }
valueType:
type: string
enum: [ATTRIBUTE_VALUE_TYPE_UNSPECIFIED, BOOL, ENUM, URL, REPEATED_ENUM]
values: { type: array, items: {} }
repeatedEnumValue:
type: object
properties:
setValues: { type: array, items: { type: string } }
unsetValues: { type: array, items: { type: string } }
uriValues:
type: array
items:
type: object
required: [uri]
properties:
uri: { type: string, format: uri }
attributeMask:
type: string
minLength: 1
description: "Comma-separated attribute names to update (e.g. 'has_delivery,has_takeout')"
example:
attributes:
- name: "has_delivery"
values: [true]
- name: "has_takeout"
values: [true]
- name: "has_outdoor_seating"
values: [false]
attributeMask: "has_delivery,has_takeout,has_outdoor_seating"
responses:
'200':
description: Attributes updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
attributes: { type: array, items: { type: object } }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-place-actions:
get:
x-resource-group: "accounts"
operationId: listGoogleBusinessPlaceActions
tags: [GMB Place Actions]
summary: List action links
description: |
Lists place action links for a Google Business Profile location.
Place actions are the booking, ordering, and reservation buttons that appear on your listing.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
- name: pageSize
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 100 }
- name: pageToken
in: query
schema: { type: string }
security:
- bearerAuth: []
responses:
'200':
description: Place actions fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
placeActionLinks:
type: array
items:
type: object
properties:
name: { type: string, description: Resource name }
uri: { type: string, description: Action URL }
placeActionType: { type: string }
createTime: { type: string, format: date-time }
updateTime: { type: string, format: date-time }
nextPageToken: { type: string }
example:
success: true
placeActionLinks:
- name: "locations/123/placeActionLinks/456"
uri: "https://order.ubereats.com/joespizza"
placeActionType: "FOOD_ORDERING"
- name: "locations/123/placeActionLinks/789"
uri: "https://www.opentable.com/joespizza"
placeActionType: "DINING_RESERVATION"
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
post:
x-resource-group: "accounts"
operationId: createGoogleBusinessPlaceAction
tags: [GMB Place Actions]
summary: Create action link
description: |
Creates a place action link for a location.
Available action types: APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location. Use GET /gmb-locations to list valid IDs.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [uri, placeActionType]
properties:
uri: { type: string, format: uri, description: The action URL }
placeActionType:
type: string
enum: [APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE]
description: Type of action
example:
uri: "https://order.ubereats.com/joespizza"
placeActionType: "FOOD_ORDERING"
responses:
'200':
description: Place action created successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
name: { type: string, description: Resource name of the created link }
uri: { type: string }
placeActionType: { type: string }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
delete:
x-resource-group: "accounts"
operationId: deleteGoogleBusinessPlaceAction
tags: [GMB Place Actions]
summary: Delete action link
description: Deletes a place action link (e.g. booking or ordering URL) from a 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).
Only the fields included in the request body will be updated.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name:
type: string
description: "Resource name of the place action link (e.g. locations/123/placeActionLinks/456)"
uri:
type: string
format: uri
description: "New action URL. At least one of uri or placeActionType is required (enforced server-side; not modeled as anyOf because required-only anyOf branches break SDK generators)."
placeActionType:
type: string
enum: [APPOINTMENT, ONLINE_APPOINTMENT, DINING_RESERVATION, FOOD_ORDERING, FOOD_DELIVERY, FOOD_TAKEOUT, SHOP_ONLINE]
description: New action type
example:
name: "locations/123/placeActionLinks/456"
uri: "https://order.doordash.com/joespizza"
responses:
'200':
description: Place action updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
name: { type: string }
uri: { type: string }
placeActionType: { type: string }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-services:
get:
x-resource-group: "accounts"
operationId: getGoogleBusinessServices
tags: [GMB Services]
summary: Get services
description: |
Gets the services offered by a Google Business Profile location.
Returns an array of service items (structured or free-form with optional price).
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to query. If omitted, uses the account's selected location.
security:
- bearerAuth: []
responses:
'200':
description: Services fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationId: { type: string }
services:
type: array
items:
type: object
properties:
structuredServiceItem:
type: object
properties:
serviceTypeId: { type: string }
description: { type: string }
freeFormServiceItem:
type: object
properties:
category: { type: string }
label:
type: object
properties:
displayName: { type: string }
description: { type: string }
price:
type: object
properties:
currencyCode: { type: string, example: "USD" }
units: { type: string, example: "50" }
nanos: { type: integer }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
put:
x-resource-group: "accounts"
operationId: updateGoogleBusinessServices
tags: [GMB Services]
summary: Replace services
description: |
Replaces the entire service list for a location.
Google's API requires full replacement; individual item updates are not supported.
Each service can be structured (using a predefined serviceTypeId) or free-form (custom label).
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: locationId
in: query
schema: { type: string }
description: Override which location to target. If omitted, uses the account's selected location.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [serviceItems]
properties:
serviceItems:
type: array
items:
type: object
properties:
structuredServiceItem:
type: object
required: [serviceTypeId]
description: "Exactly one of structuredServiceItem or freeFormServiceItem is required per item (enforced server-side; not modeled as oneOf because required-only oneOf branches break SDK generators)."
properties:
serviceTypeId: { type: string, minLength: 1 }
description: { type: string }
freeFormServiceItem:
type: object
required: [category, label]
properties:
category: { type: string, minLength: 1 }
label:
type: object
required: [displayName]
properties:
displayName: { type: string, minLength: 1 }
description: { type: string }
languageCode: { type: string }
price:
type: object
properties:
currencyCode: { type: string, minLength: 1 }
units: { type: string, minLength: 1 }
nanos: { type: integer }
example:
serviceItems:
- freeFormServiceItem:
category: "categories/gcid:plumber"
label: { displayName: "Pipe Repair", description: "Emergency and scheduled pipe repair" }
price: { currencyCode: "USD", units: "150" }
responses:
'200':
description: Services updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
services: { type: array, items: { type: object } }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-reviews/batch:
post:
x-resource-group: "engagement"
operationId: batchGetGoogleBusinessReviews
tags: [GMB Reviews]
summary: Batch get reviews
description: |
Fetches reviews across multiple locations in a single request.
More efficient than calling GET /gmb-reviews per location for multi-location businesses.
Returns a flat locationReviews array (not grouped by location): each item carries
the location resource name it belongs to (`name`) plus the review object (`review`),
whose identity is `review.reviewId`.
Reviews are requested from Google ordered by `orderBy` (default `updateTime desc`,
newest first), so callers polling for recent reviews can stop paginating once they
cross their date window.
Note: this endpoint does not return aggregate metrics (averageRating / totalReviewCount).
For those, use the single-location GET /gmb-reviews endpoint.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [locationNames]
properties:
locationNames:
type: array
minItems: 1
maxItems: 50
items: { type: string, pattern: '^accounts/[^/]+/locations/[^/]+$' }
description: "Array of full location resource names (e.g. ['accounts/123/locations/456']). Max 50 per request (Google's batchGetReviews cap); chunk larger sets into multiple requests."
pageSize:
type: integer
minimum: 1
maximum: 50
default: 50
description: Number of reviews per page (max 50)
pageToken:
type: string
minLength: 1
description: Pagination token from previous response
orderBy:
type: string
enum: ['updateTime desc', 'rating', 'rating desc']
default: 'updateTime desc'
description: "Sort order requested from Google. Defaults to 'updateTime desc' (newest first), which allows early-stopping pagination once results cross your date window."
example:
locationNames: ["accounts/123/locations/456", "accounts/123/locations/789"]
pageSize: 50
responses:
'200':
description: Batch reviews fetched successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
accountId: { type: string }
locationReviews:
type: array
items:
type: object
properties:
name: { type: string, description: 'LOCATION resource name the review belongs to (accounts/{accountId}/locations/{locationId}) - NOT the review resource name. Use it to attribute the review to a location; the review identity is review.reviewId (full review resource name at review.name).' }
review: { type: object, description: "The review object: reviewId (the review's identity), name (full review resource name, accounts/*/locations/*/reviews/*), starRating, comment, reviewer, createTime, updateTime, reviewReply, and reviewMediaItems (review photos/videos; photo items carry thumbnailUrl, video items carry videoUrl)" }
nextPageToken: { type: string }
'400':
description: Invalid request
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401':
description: Unauthorized
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
/v1/accounts/{accountId}/gmb-reviews/{reviewId}:
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.
The 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.
Read the review before replying if a human may have answered it already: replies are overwritten in place and Google keeps no history.
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.
The reply is associated with the account's currently selected location (set via /v1/accounts/{accountId}/gmb-locations).
Calling this endpoint a second time on the same review overwrites the previous reply (PUT semantics on Google's side).
Google keeps no history, so an automated retry silently replaces a reply someone edited by hand in the Google Business Profile UI.
Read the review before retrying if a human may have answered it.
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.
**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`).
Reading 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.
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.
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.
To get your userId for the state parameter, call GET /v1/users which includes a currentUserId field.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [identifier, appPassword, state]
properties:
identifier:
type: string
description: Your Bluesky handle (e.g. user.bsky.social) or email address
appPassword:
type: string
description: App password generated from Bluesky Settings > App Passwords
state:
type: string
description: Required state formatted as {userId}-{profileId}. Get userId from GET /v1/users and profileId from GET /v1/profiles.
example: "6507a1b2c3d4e5f6a7b8c9d0-6507a1b2c3d4e5f6a7b8c9d1"
redirectUri:
type: string
format: uri
description: Optional URL to redirect to after successful connection
example:
identifier: "yourhandle.bsky.social"
appPassword: "xxxx-xxxx-xxxx-xxxx"
state: "6507a1b2c3d4e5f6a7b8c9d0-6507a1b2c3d4e5f6a7b8c9d1"
redirectUri: "https://yourapp.com/connected"
responses:
'200':
description: Bluesky connected successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
account: { $ref: '#/components/schemas/SocialAccount' }
example:
message: "Bluesky connected successfully"
account:
platform: "bluesky"
username: "yourhandle.bsky.social"
displayName: "Your Name"
isActive: true
redirectUrl: "https://zernio.com/dashboard/profiles/64f0.../accounts"
'400': { description: Invalid request - missing fields or invalid state format }
'401': { $ref: '#/components/responses/Unauthorized' }
'500': { description: Internal error }
/v1/connect/openai-ads/credentials:
post:
x-resource-group: "accounts"
operationId: connectOpenAIAdsCredentials
tags: [Connect]
summary: Connect an OpenAI Ads account
description: |
Connect an OpenAI Ads account using an API key from ChatGPT Ads Manager.
The key grants full campaign write access on OpenAI's side (OpenAI does
not offer a read-only key scope). Zernio uses it to read ads and
performance, and to create and manage campaigns you set up through
Zernio (create, status, budget, and cancel). Campaigns created
directly in ChatGPT Ads Manager can still be managed there.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [apiKey, profileId]
properties:
apiKey:
type: string
description: "API key from ChatGPT Ads Manager (Settings). Grants full read/write access on OpenAI's side; Zernio only ever reads with it."
profileId:
type: string
description: Your Zernio profile ID
state:
type: string
description: Optional state passthrough for the connect flow.
redirectUri:
type: string
format: uri
description: Optional URL to redirect to after successful connection
example:
apiKey: "sk-ads-xxxxxxxxxxxxxxxxxxxx"
profileId: "6507a1b2c3d4e5f6a7b8c9d0"
responses:
'200':
description: OpenAI Ads connected successfully
content:
application/json:
schema:
type: object
properties:
accountId: { type: string }
adAccountName: { type: string }
redirectUrl: { type: string }
example:
accountId: "6507a1b2c3d4e5f6a7b8c9d0"
adAccountName: "Acme US"
'400': { $ref: '#/components/responses/BadRequest' }
'401':
description: "Unauthorized, or the API key could not read an OpenAI ad account (code invalid_credentials)."
'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.
This is the headless alternative to the Embedded Signup browser flow.
To get the required credentials:
1. Go to Meta Business Suite (business.facebook.com)
2. Create or select a WhatsApp Business Account
3. In Business Settings > System Users, create a System User
4. Assign it the whatsapp_business_management and whatsapp_business_messaging permissions
5. Generate a permanent access token
6. Get the WABA ID from WhatsApp Manager > Account Tools > Phone Numbers
7. Get the Phone Number ID from the same page (click on the number)
Warning: connecting subscribes your own Meta app to this WABA with an override
callback that redirects its webhook delivery to Zernio. This WABA's events stop
reaching any callback URL you had configured before, immediately and with no
overlap window. Do not unsubscribe your app from the WABA afterward: that also
cuts off Zernio's delivery, and recovery requires calling this endpoint again.
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
phoneNumberId was not found in the specified WABA. If the phone was not
found, the response includes availablePhoneNumbers to help identify the
correct ID.
'401':
description: Invalid or expired access token
'403':
description: Profile limit exceeded for this plan
'409':
description: '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.
WhatsApp OAuth grants access at the WABA level. When a connected WABA has 2 or more phone numbers, you must call this endpoint to list them and then `POST /v1/connect/whatsapp/select-phone-number` to bind one to the Zernio profile. Single-phone WABAs auto-complete during the OAuth callback and never reach this endpoint.
Use the `profileId` and `tempToken` returned in the headless redirect (`step=select_phone_number`).
Alternative: if you already know `wabaId` and `phoneNumberId` (e.g. from Meta Business Suite), use `connectWhatsAppCredentials` instead, which skips this two-step flow.
security:
- bearerAuth: []
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
description: The Zernio profile ID from the headless redirect
- name: tempToken
in: query
required: true
schema: { type: string }
description: The temporary access token from the headless redirect
- name: X-Connect-Token
in: header
required: false
schema: { type: string }
description: Alternative auth for API users' end customers (used when the bearer token is scoped to a different user)
responses:
'200':
description: Phone numbers fetched successfully
content:
application/json:
schema:
type: object
properties:
phoneNumbers:
type: array
items:
type: object
description: Phone number entry. Field names use Meta WhatsApp Cloud API snake_case (passed through unchanged); wabaId and wabaName are Zernio enrichment.
properties:
id: { type: string, description: Phone Number ID (Meta) }
display_phone_number: { type: string, description: E.164-formatted display number }
verified_name: { type: string, description: Meta-verified business name }
quality_rating: { type: string, description: 'GREEN, YELLOW, RED, or UNKNOWN' }
name_status: { type: string, description: 'APPROVED, PENDING_REVIEW, DECLINED, or NONE' }
messaging_limit_tier: { type: string, description: 'TIER_250, TIER_1K, TIER_10K, TIER_100K, or TIER_UNLIMITED' }
wabaId: { type: string, description: WhatsApp Business Account ID (Zernio enrichment) }
wabaName: { type: string, description: WABA display name (Zernio enrichment) }
example:
phoneNumbers:
- id: "1875844705851813"
display_phone_number: "+55 83 8793-2039"
verified_name: "Bioface"
quality_rating: "GREEN"
name_status: "APPROVED"
messaging_limit_tier: "TIER_1K"
wabaId: "317766992490131"
wabaName: "Bioface WABA"
'400':
description: Missing profileId or tempToken
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
'500':
description: Failed to fetch phone numbers (Meta API error, expired token, or insufficient permissions)
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
post:
x-resource-group: "accounts"
operationId: completeWhatsAppPhoneSelection
tags: [Connect]
summary: Complete number selection
description: |
Bind a specific WhatsApp phone number to the Zernio profile after the user picks one from `listWhatsAppPhoneNumbers`. Exchanges the short-lived OAuth token for a long-lived token, subscribes the WABA to webhooks, and creates the SocialAccount.
security:
- bearerAuth: []
parameters:
- name: X-Connect-Token
in: header
required: false
schema: { type: string }
description: Alternative auth for API users' end customers
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, phoneNumberId, wabaId, tempToken]
properties:
profileId: { type: string, description: The Zernio profile ID }
phoneNumberId: { type: string, description: The selected phone number ID (from listWhatsAppPhoneNumbers) }
wabaId: { type: string, description: The WABA ID containing the selected phone }
tempToken: { type: string, description: The temporary access token from the headless redirect }
userProfile:
type: object
description: Optional user profile data (passthrough)
redirect_url:
type: string
description: Optional URL to receive the post-connection redirect target
example:
profileId: "6507a1b2c3d4e5f6a7b8c9d0"
phoneNumberId: "1875844705851813"
wabaId: "317766992490131"
tempToken: "EAABsbCS...short-lived-token"
responses:
'200':
description: Phone number connected successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
redirect_url: { type: string, description: 'Present only if redirect_url was provided in the request' }
account:
type: object
properties:
accountId: { type: string }
platform: { type: string, enum: [whatsapp] }
username: { type: string, description: Display phone number }
displayName: { type: string, description: Meta-verified business name }
isActive: { type: boolean }
selectedPhoneNumber: { type: string }
example:
message: "WhatsApp phone number connected successfully"
account:
accountId: "6507a1b2c3d4e5f6a7b8c9d0"
platform: "whatsapp"
username: "+55 83 8793-2039"
displayName: "Bioface"
isActive: true
selectedPhoneNumber: "+55 83 8793-2039"
'400':
description: Missing required fields (profileId, phoneNumberId, wabaId, or tempToken)
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Profile limit exceeded for the user's plan (PROFILE_LIMIT_EXCEEDED)
'404':
description: Selected phone number not found in the specified WABA
content:
application/json:
schema: { $ref: '#/components/schemas/ErrorResponse' }
'409':
description: '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
signup page makes after the popup closes (`GET /v1/connect/whatsapp?signup=hosted`), sending the `wabaId`
and `phoneNumberId` Meta reported so exactly the chosen number is connected; when both are omitted the
first number the token can see is used. The code never passes through a `redirect_uri`, so
`POST /v1/connect/{platform}` cannot accept it. Authenticates with an API key, or with the connect token
the hosted flow issues (`X-Connect-Token` header).
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
popup. Integrators do not need this endpoint: start the hosted flow with
`GET /v1/connect/whatsapp?signup=hosted` and send the user to the returned `authUrl`. Authenticates with
an API key or with the connect token the hosted flow issues (`X-Connect-Token` header).
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
workspace, not a channel, so after the redirect the caller lists the workspace's channels
here and finalizes one with `POST /v1/connect/slack`. Served by a dedicated route that
shadows `GET /v1/connect/{platform}` for `slack`.
Send exactly one of `pendingDataToken` (first connect: the nonce from the OAuth redirect,
bound to the same `profileId`) or `accountId` (add another channel to a workspace already
connected: the existing Slack account's workspace token is reused, no re-OAuth). With
neither, the endpoint behaves like `GET /v1/connect/{platform}` and returns `authUrl` and
`state` to start the OAuth flow.
Channels are read live from Slack (`conversations.list`, public and private, archived
excluded, up to 2,000). `isMember` says whether the Zernio bot is already in the channel:
a public channel is joined automatically on finalize, a private one must be invited
(`/invite @Zernio`) first.
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.
Status values: pending (waiting for user), connected (channel/group linked), expired (generate a new code).
parameters:
- name: code
in: query
required: true
schema: { type: string }
description: The access code to check status for
example: "ZRN-ABC123"
responses:
'200':
description: Connection status
content:
application/json:
schema:
oneOf:
- type: object
title: Pending
properties:
status: { type: string, enum: [pending] }
expiresAt: { type: string, format: date-time }
expiresIn: { type: integer, description: Seconds until expiration }
- type: object
title: Connected
properties:
status: { type: string, enum: [connected] }
chatId: { type: string }
chatTitle: { type: string }
chatType: { type: string, enum: [channel, group, supergroup] }
account:
type: object
properties:
_id: { type: string }
platform: { type: string }
username: { type: string }
displayName: { type: string }
- type: object
title: Expired
properties:
status: { type: string, enum: [expired] }
message: { type: string }
examples:
pending:
summary: Waiting for connection
value:
status: "pending"
expiresAt: "2024-01-15T12:30:00.000Z"
expiresIn: 542
connected:
summary: Successfully connected
value:
status: "connected"
chatId: "-1001234567890"
chatTitle: "My Channel"
chatType: "channel"
account:
_id: "64e1f0a9e2b5af0012ab34cd"
platform: "telegram"
username: "mychannel"
displayName: "My Channel"
expired:
summary: Code expired
value:
status: "expired"
message: "Access code has expired. Please generate a new one."
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Code not found }
'500': { description: Internal error }
/v1/accounts/{accountId}/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.
Use it to confirm `leadgen` is present: a Page missing it keeps delivering every other event while
lead ads stop arriving, with nothing to indicate it.
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.
Meta only honours the field set sent at subscribe time, so a Page connected before a field
existed stays without it until this runs. The response reflects what Meta actually granted,
not what was requested.
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.
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"
'503': { $ref: '#/components/responses/TemporarilyUnavailable' }
'502': { $ref: '#/components/responses/UpstreamFailure' }
/v1/accounts/{accountId}/linkedin-post-reactions:
get:
x-resource-group: "analytics"
operationId: getLinkedInPostReactions
tags: [Analytics]
summary: Get LinkedIn post reactions
description: |
Returns individual reactions for a specific LinkedIn post, including reactor profiles
(name, headline/job title, profile picture, profile URL, reaction type).
Only works for organization/company page accounts. LinkedIn restricts reaction
data for personal profiles (r_member_social_feed is a closed permission).
parameters:
- name: accountId
in: path
required: true
description: The ID of the LinkedIn organization account
schema: { type: string }
- name: urn
in: query
required: true
description: The LinkedIn post URN
schema: { type: string, pattern: '^urn:li:(share|ugcPost|activity):[0-9]+$' }
example: "urn:li:share:7123456789012345678"
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
description: Maximum number of reactions to return per page
- name: cursor
in: query
schema: { type: integer, minimum: 0, default: 0 }
description: Offset-based pagination start index
responses:
'200':
description: Reactions with reactor profiles
content:
application/json:
schema:
type: object
properties:
accountId: { type: string }
platform: { type: string, example: linkedin }
accountType: { type: string, example: organization }
username: { type: string }
postUrn: { type: string }
reactions:
type: array
items:
type: object
properties:
reactionType:
type: string
description: LinkedIn reaction enum (LIKE, PRAISE, EMPATHY, INTEREST, APPRECIATION, ENTERTAINMENT)
reactionLabel:
type: string
description: User-friendly label (Like, Celebrate, Love, Insightful, Support, Funny)
reactedAt: { type: string, format: date-time }
from:
type: object
properties:
urn: { type: string, description: "LinkedIn person or organization URN" }
name: { type: string, description: "Reactor's display name" }
headline: { type: string, description: "Reactor's headline/job title" }
username: { type: string, description: "LinkedIn vanity name" }
profilePicture: { type: string, description: "Profile picture URL" }
profileUrl: { type: string, description: "Direct link to LinkedIn profile" }
pagination:
type: object
properties:
hasMore: { type: boolean }
cursor: { type: string, description: "Offset for next page" }
total: { type: integer, description: "Total number of reactions (when available)" }
lastUpdated: { type: string, format: date-time }
example:
accountId: "64abc123def456"
platform: "linkedin"
accountType: "organization"
username: "Acme Corp"
postUrn: "urn:li:share:7123456789012345678"
reactions:
- reactionType: "LIKE"
reactionLabel: "Like"
reactedAt: "2026-03-08T12:00:00.000Z"
from:
urn: "urn:li:person:abc123"
name: "Jane Smith"
headline: "Product Manager at Acme Corp"
username: "janesmith"
profilePicture: "https://media.licdn.com/..."
profileUrl: "https://www.linkedin.com/in/janesmith"
pagination:
hasMore: true
cursor: "25"
total: 156
lastUpdated: "2026-03-08T12:00:00.000Z"
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'402':
description: Analytics access required. Legacy plans need the Analytics add-on; included by default on usage-based plans.
'403':
description: Missing required LinkedIn scope
'404':
description: Account or post not found
/v1/accounts/{accountId}/linkedin-organization:
put:
x-resource-group: "accounts"
operationId: updateLinkedInOrganization
tags: [Connect]
summary: Switch LinkedIn account type
description: Switch a LinkedIn account between personal profile and organization (company page) posting.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountType]
properties:
accountType: { type: string, enum: [personal, organization] }
selectedOrganization: { type: object }
example:
accountType: "organization"
selectedOrganization:
id: "12345678"
urn: "urn:li:organization:12345678"
name: "Acme Corporation"
vanityName: "acme-corp"
responses:
'200':
description: Account updated
content:
application/json:
schema:
type: object
properties:
message: { type: string }
accountType: { type: string, enum: [personal, organization] }
accountName: { type: string }
example:
message: "LinkedIn account updated successfully"
accountType: "organization"
accountName: "Acme Corporation"
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/accounts/{accountId}/linkedin-mentions:
get:
x-resource-group: "accounts"
operationId: getLinkedInMentions
tags: [LinkedIn Mentions]
summary: Resolve LinkedIn mention
description: |
Converts a LinkedIn profile or company URL to a URN for @mentions in posts.
How to use LinkedIn @mentions (2-step workflow):
1. Call this endpoint with the LinkedIn profile/company URL to get the mention URN and format.
2. Embed the returned mentionFormat (e.g. @[Vincent Jong](urn:li:person:xxx)) directly in your post's content field.
Example:
- Resolve: GET /v1/accounts/{id}/linkedin-mentions?url=linkedin.com/in/vincentjong&displayName=Vincent Jong
- Returns: mentionFormat: "@[Vincent Jong](urn:li:person:xxx)"
- Use in post content: "Great talk with @[Vincent Jong](urn:li:person:xxx) today!"
Important: The mentions array field in POST /v1/posts is stored for reference only and does NOT trigger @mentions on LinkedIn. You must embed the mention format directly in the content text.
Requirements:
- Person mentions require the LinkedIn account to be admin of at least one organization: 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.
- 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.
- For person mentions to be clickable, the displayName parameter must exactly match the name shown on their LinkedIn profile.
- Person mentions DO work when published from personal profiles (the URN only needs to be valid). The limitation is only in the resolution step (URL to URN), not in publishing.
parameters:
- name: accountId
in: path
required: true
description: The LinkedIn account ID
schema: { type: string }
- name: url
in: query
required: true
description: LinkedIn profile URL, company URL, or vanity name.
schema: { type: string }
examples:
personVanityName:
value: "miquelpalet"
summary: Person, 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.
Meta keeps stories live for 24h; expired stories are not returned.
Limitations propagated from Meta (these are NOT bugs):
- 24h window only
- Live videos excluded
- Reshared stories not returned
- `mediaUrl` may be null if Meta flagged the story for copyright
- `caption`, `likeCount`, `commentsCount` do not apply to story media
parameters:
- name: accountId
in: path
required: true
description: The Instagram account ID
schema: { type: string }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
/v1/accounts/{accountId}/instagram/publishing-limit:
get:
x-resource-group: "accounts"
operationId: getInstagramPublishingLimit
tags: [Instagram]
summary: Get Instagram publishing limit
description: |
Returns the account's remaining content-publishing quota for Instagram's rolling
24-hour window, so you can pace publishing and warn before the cap is reached.
`quotaUsage` counts containers published since the start of the window.
Always compare against the returned `quotaTotal` rather than hardcoding a number:
Meta's prose documentation and the live API disagree on the value, and the live
value is authoritative.
parameters:
- name: accountId
in: path
required: true
description: The ID of the Instagram account
schema: { type: string }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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),
or list what is currently trending by omitting `q`. Returns up to ~30
assets; Meta exposes no pagination on this edge.
Pass the returned `audioId` as
`platformSpecificData.audioConfiguration.audioId` when creating a Reel
to publish it with that track.
Requires an Instagram account connected via **Facebook Login**. Meta
hosts this catalog on graph.facebook.com only, so accounts connected
with classic Instagram Login receive a 400
(`instagram_audio_requires_facebook_login`) and must be reconnected
choosing the Facebook option.
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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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
`audioId` before a scheduled Reel publishes, or to refresh the preview
`downloadUrl` (Meta expires preview URLs after roughly 1.5 days).
Same connection requirement as the search endpoint: Facebook-Login
Instagram accounts only.
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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'502': { description: Instagram rejected the request }
/v1/accounts/{accountId}/instagram/stories/{storyId}/insights:
get:
x-resource-group: "analytics"
operationId: getInstagramStoryInsights
tags: [Instagram]
summary: Get Instagram story insights
description: |
Returns metrics for a single story. The `source` field discriminates
between three states:
- `live`: fetched from Meta in real time (story is still active)
- `cached`: fetched from a persisted `story_insights` webhook payload
(story has expired but we received its final-state metrics from Meta)
- `unavailable`: story has expired and we never received its webhook
payload (for example, the account connected after the story expired)
Meta can report an expired story as an empty successful result rather
than an error, so an expired story resolves to `cached` or `unavailable`
even though the upstream request itself succeeded.
Field semantics follow Meta's API. Counts below 5 may be returned as 0
due to Meta's privacy floor on small audiences. The `navigation` field
is the sum of `tapsForward + tapsBack + exits + swipesForward`.
parameters:
- name: accountId
in: path
required: true
description: The Instagram account ID
schema: { type: string }
- name: storyId
in: path
required: true
description: The Instagram media ID of the story.
schema: { type: string }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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.
Auto-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.
Caching: 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.
Notes:
- Only videos owned by this connected channel. Anything else returns 404.
- `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.
- YouTube generates auto-captions only for videos with recognisable speech, and can take a few hours after upload to publish them.
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.
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.
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.
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).
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.
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
plus the overall total.
The whole breakdown is fetched in a single Graph call. The post analytics
endpoint reports only an aggregate reaction count (surfaced there as `likes`), so use
this endpoint when you need per-type counts.
parameters:
- name: accountId
in: path
required: true
description: The ID of the Facebook Page account
schema: { type: string }
- name: postId
in: query
required: true
description: The Facebook post ID
schema: { type: string }
example: "1234567890_9876543210"
responses:
'200':
description: Reaction breakdown for the post
content:
application/json:
schema:
type: object
properties:
accountId: { type: string }
platform: { type: string, example: facebook }
username: { type: string }
postId: { type: string }
total: { type: integer, description: Total reactions across all types }
breakdown:
type: object
description: Count per reaction type. A type with no reactions returns 0.
properties:
like: { type: integer }
love: { type: integer }
haha: { type: integer }
wow: { type: integer }
sad: { type: integer }
angry: { type: integer }
care: { type: integer }
lastUpdated: { type: string, format: date-time }
example:
accountId: "60f7a1b2c3d4e5f6a7b8c9d0"
platform: "facebook"
username: "My Page"
postId: "1234567890_9876543210"
total: 28
breakdown: { like: 10, love: 5, haha: 4, wow: 3, sad: 2, angry: 1, care: 3 }
lastUpdated: "2026-07-09T09:43:50.000Z"
'400': { description: 'Invalid accountId format, not a Facebook account, or missing postId parameter' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
'502': { description: Facebook rejected the request }
/v1/accounts/{accountId}/reddit-subreddits:
get:
x-resource-group: "accounts"
operationId: getRedditSubreddits
tags: [Connect]
summary: List Reddit subreddits
description: Returns the subreddits the connected Reddit account can post to. Use this to get a subreddit name when creating a Reddit post.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Subreddits list
content:
application/json:
schema:
type: object
properties:
subreddits:
type: array
items:
type: object
properties:
id: { type: string, description: Reddit subreddit ID }
name: { type: string, description: Subreddit name without r/ prefix }
title: { type: string, description: Subreddit title }
url: { type: string, description: Subreddit URL path }
over18: { type: boolean, description: Whether the subreddit is NSFW }
defaultSubreddit:
type: string
description: Currently set default subreddit for posting
example:
subreddits:
- id: "2qh1i"
name: "marketing"
title: "Marketing"
url: "/r/marketing/"
over18: false
- id: "2qh3l"
name: "socialmedia"
title: "Social Media"
url: "/r/socialmedia/"
over18: false
defaultSubreddit: "marketing"
'400': { description: Not a Reddit account }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
put:
x-resource-group: "accounts"
operationId: updateRedditSubreddits
tags: [Connect]
summary: Set default subreddit
description: Sets the default subreddit used when publishing posts for this Reddit account.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [defaultSubreddit]
properties:
defaultSubreddit: { type: string, minLength: 1 }
example:
defaultSubreddit: "marketing"
responses:
'200':
description: Default subreddit set
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
example:
success: true
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/accounts/{accountId}/reddit-subreddits/{subreddit}/rules:
get:
x-resource-group: "accounts"
operationId: getSubredditRules
tags: [Connect]
summary: Get subreddit rules
description: |
Returns a subreddit's posting rules plus Reddit's site-wide rules, so you can check
them before submitting and avoid a removal.
Use this alongside `POST /v1/tools/validate/subreddit`, which only confirms that a
subreddit exists and reports its basic posting settings.
parameters:
- name: accountId
in: path
required: true
description: The ID of the Reddit account
schema: { type: string }
- name: subreddit
in: path
required: true
description: Subreddit name (without the "r/" prefix)
schema: { type: string }
example: "webdev"
responses:
'200':
description: Subreddit and site rules
content:
application/json:
schema:
type: object
properties:
rules:
type: array
items:
type: object
properties:
kind: { type: string, description: "Scope of the rule: 'link', 'comment', or 'all'" }
shortName: { type: string, description: Short rule title shown in the subreddit sidebar }
description: { type: string, description: Full rule text }
violationReason: { type: string, description: Reason shown to a user when the rule is enforced }
createdUtc: { type: number, description: Unix timestamp when the rule was created }
priority: { type: integer, description: Display order of the rule }
siteRules:
type: array
description: Reddit's site-wide content policy rules
items: { type: string }
example:
rules:
- kind: "link"
shortName: "No self-promotion"
description: "Posts that primarily promote your own product will be removed."
violationReason: "Self-promotion"
createdUtc: 1611234567
priority: 0
siteRules:
- "Spam"
- "Personal and confidential information"
'400': { description: Not a Reddit account }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account or subreddit not found }
'502': { description: Reddit was unreachable or returned an unclassified error. Reddit 4xx statuses are forwarded as-is. }
/v1/accounts/{accountId}/reddit-vote:
post:
x-resource-group: "engagement"
operationId: voteRedditThing
tags: [Connect]
summary: Vote on a Reddit post or comment
description: |
Cast, change, or clear the connected account's vote on a Reddit post or comment.
**Reddit requires that votes be cast by humans.** Reddit's API terms permit a client
to proxy a human's action one-for-one, and prohibit a bot from deciding how to vote
or from amplifying a human's vote. Call this endpoint only in direct response to an
explicit action by the account owner. Automated or agent-decided voting is
vote manipulation and puts API access at risk.
parameters:
- name: accountId
in: path
required: true
description: The ID of the Reddit account casting the vote
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [thingId, direction]
properties:
thingId:
type: string
description: >
Reddit fullname of the target. Prefix "t3_" for a post and "t1_" for a
comment. A bare id with no prefix is treated as a post ("t3_").
example: "t3_abc123"
direction:
type: integer
description: "1 to upvote, -1 to downvote, 0 to clear an existing vote"
enum: [1, 0, -1]
example:
thingId: "t3_abc123"
direction: 1
responses:
'200':
description: Vote registered
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
example: { success: true }
'400': { description: "Not a Reddit account, or invalid thingId/direction" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
'502': { description: Reddit was unreachable or returned an unclassified error. Reddit 4xx statuses are forwarded as-is. }
/v1/accounts/{accountId}/reddit-flairs:
get:
x-resource-group: "accounts"
operationId: getRedditFlairs
tags: [Connect]
summary: List subreddit flairs
description: Returns available post flairs for a subreddit. Some subreddits require a flair when posting.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: subreddit
in: query
required: true
schema: { type: string, minLength: 1 }
description: Subreddit name (without "r/" prefix) to fetch flairs for
responses:
'200':
description: Flairs list
content:
application/json:
schema:
type: object
properties:
flairs:
type: array
items:
type: object
properties:
id: { type: string, description: Flair ID to pass as flairId in platformSpecificData }
text: { type: string, description: Flair display text }
textColor: { type: string, description: "Text color: 'dark' or 'light'" }
backgroundColor: { type: string, description: "Background hex color (e.g. '#ff4500')" }
example:
flairs:
- id: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
text: "Discussion"
textColor: "dark"
backgroundColor: "#edeff1"
- id: "b2c3d4e5-f6a7-8901-bcde-f12345678901"
text: "News"
textColor: "light"
backgroundColor: "#ff4500"
'400': { description: Not a Reddit account or missing subreddit parameter }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
post:
x-resource-group: "accounts"
operationId: setRedditPostFlair
tags: [Connect]
summary: Set Reddit post flair
description: |
Applies a flair to a post the connected account already published. Use the GET on this
path to list the available `flairTemplateId` values for the subreddit.
Flair can also be set at submit time by passing `flairId` in `platformSpecificData`
when creating the post. This endpoint is for changing it afterwards.
The subreddit must allow users to select their own post flair. Setting flair on
another user's post requires moderator permissions, which Zernio does not request.
parameters:
- name: accountId
in: path
required: true
description: The ID of the Reddit account that owns the post
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [subreddit, postId, flairTemplateId]
properties:
subreddit: { type: string, minLength: 1, description: Subreddit name (without the "r/" prefix) }
postId: { type: string, minLength: 1, description: 'Reddit post id, with or without the t3_ prefix' }
flairTemplateId: { type: string, minLength: 1, description: Flair template id from the GET on this path }
text: { type: string, description: "Optional override text, only for editable flair templates" }
example:
subreddit: "webdev"
postId: "t3_abc123"
flairTemplateId: "a1b2c3d4-e5f6-7890-abcd-ef1234567890"
responses:
'200':
description: Flair applied
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
example: { success: true }
'400': { description: "Not a Reddit account, or missing subreddit/postId/flairTemplateId" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
'502': { description: "Reddit was unreachable or returned an unclassified error. Reddit 4xx statuses (e.g. subreddit does not allow user flair selection) are forwarded as-is." }
/v1/accounts/{accountId}/slack-settings:
get:
x-resource-group: "accounts"
operationId: getSlackSettings
tags: [Accounts]
summary: Get Slack account settings
description: 'Returns the connected Slack channel details and the default message identity (name and avatar shown as the author on every post, with Slack''s APP badge). The identity applies to messages only; the app''s own Slack profile is global and cannot be changed per workspace.'
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Slack account settings
content:
application/json:
schema:
type: object
properties:
account:
type: object
properties:
_id: { type: string }
platform: { type: string, example: slack }
displayName: { type: [string, "null"] }
channelId: { type: [string, "null"] }
channelName: { type: [string, "null"] }
channelType: { type: [string, "null"], description: 'public or private' }
teamId: { type: [string, "null"] }
teamName: { type: [string, "null"] }
defaultUsername: { type: [string, "null"] }
defaultIconUrl: { type: [string, "null"] }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
patch:
x-resource-group: "accounts"
operationId: updateSlackSettings
tags: [Accounts]
summary: Update Slack account settings
description: 'Set or clear the default message identity for this channel. Empty string clears a field; per-post platformSpecificData.username/iconUrl still override these defaults.'
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
defaultUsername: { type: string, maxLength: 80, description: 'Author name shown on posts. Empty string clears it.' }
defaultIconUrl: { type: string, description: 'Author avatar image URL. Empty string clears it.' }
responses:
'200':
description: Updated settings
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/accounts/{accountId}/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):
1. **Webhook identity** - Set the default display name and avatar that appear as the message author on every post. These are account-level defaults; individual posts can override them via platformSpecificData.webhookUsername / webhookAvatarUrl.
2. **Switch channel** - Move the connection to a different channel in the same guild. A new webhook is automatically created in the target channel.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
webhookUsername:
type: string
description: Custom display name for the webhook (1-80 chars). Empty string resets to default ("Zernio"). Cannot contain "clyde" or "discord".
webhookAvatarUrl:
type: string
description: Custom avatar URL. Empty string resets to default bot avatar.
channelId:
type: string
description: Switch to a different channel in the same guild. Must be a text (0), announcement (5), or forum (15) channel.
examples:
identity:
summary: Update webhook identity
value:
webhookUsername: "My Brand"
webhookAvatarUrl: "https://example.com/logo.png"
channel:
summary: Switch channel
value:
channelId: "9999999999999999999"
responses:
'200':
description: Settings updated
content:
application/json:
schema:
type: object
properties:
message: { type: string, example: Discord settings updated }
account:
type: object
properties:
_id: { type: string }
platform: { type: string }
username: { type: string }
displayName: { type: string }
profilePicture: { type: string }
channelId: { type: string }
channelName: { type: string }
channelType: { type: string }
guildId: { type: string }
webhookUsername: { type: [string, "null"] }
webhookAvatarUrl: { type: [string, "null"] }
'400': { description: "Invalid request (no changes, invalid channel type, or bot cannot access channel)" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found }
/v1/accounts/{accountId}/discord-channels:
get:
x-resource-group: "accounts"
operationId: getDiscordChannels
tags: [Discord]
summary: List Discord guild channels
description: Returns the text, announcement, and forum channels in the connected Discord guild. Use this to discover available channels when switching the connected channel via PATCH /v1/accounts/{accountId}/discord-settings.
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Channel list
content:
application/json:
schema:
type: object
properties:
channels:
type: array
items:
type: object
properties:
id: { type: string, description: Channel snowflake ID }
name: { type: string, description: Channel name }
type: { type: integer, description: "Channel type: 0 (text), 5 (announcement), 15 (forum)" }
example:
channels:
- id: "1234567890123456789"
name: "general"
type: 0
- id: "2345678901234567890"
name: "announcements"
type: 5
- id: "3456789012345678901"
name: "feedback"
type: 15
'400': { description: Not a Discord account or missing guild info }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Account not found }
/v1/accounts/{accountId}/slack-members:
get:
x-resource-group: "contacts"
operationId: listSlackMembers
tags: [Slack]
summary: List Slack workspace members
description: 'Members of the connected Slack workspace that can receive a direct message, for populating a recipient picker. Bots, deactivated members and Slackbot are excluded. Start a DM by passing a member id as `participantId` to POST /v1/inbox/conversations.'
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
- name: query
in: query
required: false
description: 'Case-insensitive filter over display name and handle.'
schema: { type: string, maxLength: 100 }
- name: limit
in: query
required: false
schema: { type: integer, minimum: 1, maximum: 2000, default: 50 }
responses:
'200':
description: Workspace members
content:
application/json:
schema:
type: object
properties:
members:
type: array
items:
type: object
properties:
id: { type: string, description: 'Slack member id (pass as participantId to start a DM).' }
name: { type: string, description: Display name }
username: { type: [string, "null"], description: Slack handle }
picture: { type: [string, "null"], description: Avatar URL }
example:
members:
- id: "U01ABCDEF"
name: "Maria García"
username: "maria"
picture: "https://avatars.slack-edge.com/maria.png"
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Slack account not found }
/v1/discord/dms:
post:
x-resource-group: "messages"
operationId: sendDiscordDirectMessage
tags: [Discord]
summary: Send a Discord Direct Message
description: |
Send a 1:1 Direct Message from the bot to a Discord user (by snowflake ID).
Supports the same payload shape as channel posts: content, embeds, media
attachments, and TTS.
Constraints (Discord platform limits):
- The bot can only DM users it shares at least one guild with.
- If the recipient has DMs disabled for non-friends, Discord returns 403
(surfaces as a 502 platform error).
- `content` capped at 2,000 chars.
- At least one of `content`, `embeds`, or `attachments` is required.
- The recipient must be identified by Discord snowflake ID (not username).
This is a dedicated endpoint rather than a `POST /v1/posts` variant because
DMs are 1:1 operational messages (onboarding, billing reminders, support
pings) with a different lifecycle than scheduled channel posts. DMs are
not persisted to `Post` / `ExternalPost` and are always sent immediately.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, userId]
properties:
accountId:
type: string
description: SocialAccount _id of the connected Discord account the bot speaks as. Caller must own the account (directly or via team membership).
example: "65a1b2c3d4e5f60718293a4b"
userId:
type: string
description: Discord snowflake ID of the recipient (15-21 digits).
example: "1234567890123456789"
content:
type: string
description: Message text, up to 2,000 characters.
maxLength: 2000
embeds:
type: array
description: Up to 10 Discord embeds. Same shape as channel-post embeds (title, description, color, fields, etc.). See DiscordPlatformData.embeds for the embed object schema.
maxItems: 10
items:
type: object
attachments:
type: array
description: "Up to 10 media attachments. Each is `{ type: image|video|gif|document, url, filename?, mimeType?, size? }`."
maxItems: 10
items:
type: object
required: [type, url]
properties:
type:
type: string
enum: [image, video, gif, document]
url:
type: string
format: uri
filename: { type: string }
mimeType: { type: string }
size: { type: integer }
tts:
type: boolean
description: Send as text-to-speech message.
example:
accountId: "65a1b2c3d4e5f60718293a4b"
userId: "1234567890123456789"
content: "Welcome to Acme! Reply STOP to opt out."
responses:
'200':
description: DM sent successfully.
content:
application/json:
schema:
type: object
properties:
messageId: { type: string, description: Discord message snowflake ID }
channelId: { type: string, description: DM channel snowflake (Discord auto-creates one per recipient pair) }
url: { type: string, description: "Direct link to the message, 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' }
'404': { description: Discord account not found or not accessible to this user. }
'403': { description: "Discord refused the message (most commonly: bot doesn't share a guild with the recipient, OR the recipient has DMs disabled). Error body contains Discord's response." }
'502': { description: Discord was unreachable or returned an unclassified error. }
/v1/discord/guilds/{guildId}/roles:
get:
x-resource-group: "engagement"
operationId: listDiscordGuildRoles
tags: [Discord]
summary: List Discord guild roles
description: |
Returns all roles in a Discord guild. Useful for building role-mention
pickers, role-permission UIs, or finding the role ID before calling
the role-assign endpoint.
Roles are returned unordered. Sort client-side by `position` if you
need Discord's UI ordering.
Caller must pass `accountId` of a Discord SocialAccount bound to this
guild (route verifies team access + guild match).
parameters:
- { name: guildId, in: path, required: true, schema: { type: string }, description: Discord guild snowflake ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this guild }
responses:
'200':
description: List of guild roles.
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
id: { type: string, description: Role snowflake ID }
name: { type: string }
color: { type: integer, description: Decimal color (0 = no color). Convert to hex via .toString(16). }
position: { type: integer, description: Position in role hierarchy (higher = more authority) }
permissions: { type: string, description: Permissions bitfield as a stringified integer }
managed: { type: boolean, description: True for integration-managed roles (bot roles) }
mentionable: { type: boolean }
hoist: { type: boolean, description: True if role is displayed separately in member list }
'400': { description: Invalid accountId or guildId format. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Discord account not found, not accessible, or not bound to this guild." }
'403': { description: Discord refused the request (bot lacks View Channels permission in the guild). }
'502': { description: Discord was unreachable or returned an unclassified error. }
post:
x-resource-group: "engagement"
operationId: createDiscordGuildRole
tags: [Discord]
summary: Create a Discord guild role
description: |
Creates a new role in the guild.
Requires the bot to hold the Manage Roles permission. Guilds that added the Zernio bot
before role management shipped must re-invite it, because Discord applies the
permission set at invite time.
Discord's role hierarchy applies: the bot cannot create a role positioned at or above
its own highest role, and cannot grant permissions it does not itself hold. Either
attempt returns a 403 carrying Discord's own error.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string }, description: Discord guild snowflake ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this guild }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string, minLength: 1, maxLength: 100 }
color: { type: integer, minimum: 0, maximum: 16777215, description: "Decimal color (0 = no color). 0xFF0000 red is 16711680." }
hoist: { type: boolean, description: Display members with this role separately in the member list }
mentionable: { type: boolean, description: Allow anyone to @mention this role }
permissions: { type: string, description: Permissions bitfield as a stringified integer }
example:
name: "Moderators"
color: 16711680
hoist: true
mentionable: false
responses:
'201':
description: Role created.
content:
application/json:
schema:
type: object
properties:
data: { $ref: '#/components/schemas/DiscordRole' }
'400': { description: "Invalid accountId, guildId, or role body." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Discord account not found, not accessible, or not bound to this guild." }
'403': { description: "Discord refused the action (bot lacks Manage Roles, or the new role would sit at or above the bot's highest role)." }
'502': { description: Discord was unreachable or returned an unclassified error. }
/v1/discord/guilds/{guildId}/roles/{roleId}:
patch:
x-resource-group: "engagement"
operationId: editDiscordGuildRole
tags: [Discord]
summary: Edit a Discord guild role
description: |
Updates a role's name, color, hoist, mentionable flag, or permission bitfield.
At least one field must be supplied. Omitted fields are left unchanged.
Requires the bot to hold Manage Roles, and the target role must sit below the bot's
highest role. See the create-role operation for the re-invite requirement.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string }, description: Discord guild snowflake ID }
- { name: roleId, in: path, required: true, schema: { type: string }, description: Discord role snowflake ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this guild }
requestBody:
required: true
content:
application/json:
schema:
type: object
minProperties: 1
properties:
name: { type: string, minLength: 1, maxLength: 100 }
color: { type: integer, minimum: 0, maximum: 16777215 }
hoist: { type: boolean }
mentionable: { type: boolean }
permissions: { type: string, description: Permissions bitfield as a stringified integer }
example:
name: "Senior Moderators"
mentionable: true
responses:
'200':
description: Role updated.
content:
application/json:
schema:
type: object
properties:
data: { $ref: '#/components/schemas/DiscordRole' }
'400': { description: "Invalid ids, or no fields supplied to edit." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Discord account not found, not accessible, or not bound to this guild." }
'403': { description: "Discord refused the action (bot lacks Manage Roles, or the target role sits at or above the bot's highest role)." }
'502': { description: Discord was unreachable or returned an unclassified error. }
delete:
x-resource-group: "engagement"
operationId: deleteDiscordGuildRole
tags: [Discord]
summary: Delete a Discord guild role
description: |
Permanently deletes a role from the guild and removes it from every member.
This cannot be undone.
Requires the bot to hold Manage Roles, and the target role must sit below the bot's
highest role.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string }, description: Discord guild snowflake ID }
- { name: roleId, in: path, required: true, schema: { type: string }, description: Discord role snowflake ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this guild }
responses:
'200':
description: Role deleted.
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
example: { success: true }
'400': { description: "Invalid accountId, guildId, or roleId format." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Discord account not found, not accessible, or not bound to this guild." }
'403': { description: "Discord refused the action (bot lacks Manage Roles, or the target role sits at or above the bot's highest role)." }
'502': { description: Discord was unreachable or returned an unclassified error. }
/v1/discord/guilds/{guildId}/members:
get:
x-resource-group: "contacts"
operationId: listDiscordGuildMembers
tags: [Discord]
summary: List Discord guild members
description: |
Cursor-paginated list of guild members. Returns Discord's raw member
objects so callers can build community-ops automation (e.g. "add role
to all members joined in the last 7 days") on the actual platform shape.
Pagination: pass `after` = the last `user.id` from the previous page.
Omit on the first call. Response includes a `nextCursor` and `hasMore`
flag so callers don't need to know Discord's pagination shape.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 1000, default: 100 }, description: Page size (1-1000). }
- { name: after, in: query, required: false, schema: { type: string }, description: Snowflake of the last member from the previous page. }
responses:
'200':
description: List of guild members.
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/DiscordGuildMember' }
pagination:
type: object
properties:
nextCursor: { type: [string, "null"], description: Pass as `after` on the next call. Null when there are no more pages. }
hasMore: { type: boolean }
'400': { description: Invalid query params. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'Discord denied access to the guild members (the bot is no longer in the guild).' }
'404': { description: Discord account not found or not in this guild. }
/v1/discord/guilds/{guildId}/members/search:
get:
x-resource-group: "contacts"
operationId: searchDiscordGuildMembers
tags: [Discord]
summary: Search Discord guild members
description: |
Search guild members whose username or nickname **starts with** the
query (Discord matches prefixes only, not substrings).
Cheaper than paginating the full member listing when you already know
who you are looking for.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: query, in: query, required: true, schema: { type: string, minLength: 1, maxLength: 100 }, description: Username or nickname prefix to match. }
- { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 1000, default: 25 } }
responses:
'200':
description: Matching guild members.
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { $ref: '#/components/schemas/DiscordGuildMember' }
'400': { description: Invalid query params. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found or not in this guild. }
/v1/discord/guilds/{guildId}/members/{userId}:
get:
x-resource-group: "contacts"
operationId: getDiscordGuildMember
tags: [Discord]
summary: Get a Discord guild member
description: |
Fetch a single guild member by Discord user id.
Cheaper than paginating the full member listing when you already know
who you are looking for.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: userId, in: path, required: true, schema: { type: string }, description: Discord user snowflake. }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: The guild member.
content:
application/json:
schema:
type: object
properties:
data: { $ref: '#/components/schemas/DiscordGuildMember' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: 'Discord account not found, or the user is not a member of this guild.' }
/v1/discord/guilds/{guildId}/members/{userId}/roles/{roleId}:
put:
x-resource-group: "contacts"
operationId: addDiscordMemberRole
tags: [Discord]
summary: Assign a role to a guild member
description: |
Assign one role to one member. Idempotent on Discord's side: re-running
on a member who already has the role is a 204 no-op.
Path shape mirrors Discord's own API (`PUT /guilds/{guild}/members/{user}/roles/{role}`)
for zero-translation mental mapping.
Bot needs MANAGE_ROLES permission in the guild AND its highest role
must be above the target role (Discord hierarchy rule). The
`@everyone` role (where roleId == guildId) cannot be assigned.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: userId, in: path, required: true, schema: { type: string }, description: Discord user snowflake to assign the role to. }
- { name: roleId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: "Role assigned (or already present, idempotent)."
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
operation: { type: string, enum: [role_assigned] }
guildId: { type: string }
userId: { type: string }
roleId: { type: string }
'400': { description: "Validation error (malformed snowflake) or @everyone manipulation attempt." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found or not in this guild. }
'403': { description: "Discord refused the request: bot lacks MANAGE_ROLES, or target role is at or above the bot's highest role." }
'502': { description: Discord was unreachable or returned an unclassified error. }
delete:
x-resource-group: "contacts"
operationId: removeDiscordMemberRole
tags: [Discord]
summary: Remove a role from a guild member
description: |
Remove one role from one member. Idempotent: removing a role the
member doesn't have returns 204 no-op.
Same permission + hierarchy constraints as the PUT counterpart.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: userId, in: path, required: true, schema: { type: string } }
- { name: roleId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: "Role removed (or was already absent, idempotent)."
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
operation: { type: string, enum: [role_removed] }
guildId: { type: string }
userId: { type: string }
roleId: { type: string }
'400': { description: Validation error. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found or not in this guild. }
'403': { description: Discord refused the request (permission or hierarchy issue). }
'502': { description: Discord was unreachable or returned an unclassified error. }
/v1/discord/channels/{channelId}/messages/{messageId}:
delete:
x-resource-group: "engagement"
operationId: deleteDiscordMessage
tags: [Discord]
summary: Delete a Discord channel message
description: |
Deletes a message from a channel, for moderation and cleanup. This cannot be undone.
Deleting a message the bot did not send requires the bot to hold the Manage Messages
permission, which the Zernio bot requests at install time. Deleting the bot's own
message needs no extra permission.
Ownership is verified by resolving the channel's guild and confirming the caller owns
a Discord account bound to it.
parameters:
- { name: channelId, in: path, required: true, schema: { type: string }, description: Discord channel snowflake ID }
- { name: messageId, in: path, required: true, schema: { type: string }, description: Discord message snowflake ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this channel's guild }
responses:
'200':
description: Message deleted.
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
example: { success: true }
'400': { description: "Invalid accountId, channelId, or messageId format." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Discord account not found, not accessible, or not bound to this channel's guild." }
'403': { description: "Discord refused the action (bot lacks Manage Messages)." }
'502': { description: Discord was unreachable or returned an unclassified error. }
/v1/discord/channels/{channelId}/messages/{messageId}/crosspost:
post:
x-resource-group: "engagement"
operationId: crosspostDiscordMessage
tags: [Discord]
summary: Crosspost Discord message
description: |
Publishes a message from an announcement channel so it propagates to every server
following that channel.
The source channel must be an announcement channel. Calling this on a regular text
channel returns a 400 before Discord is contacted, because Discord's own error for
this case is opaque.
parameters:
- { name: channelId, in: path, required: true, schema: { type: string }, description: Discord announcement channel snowflake ID }
- { name: messageId, in: path, required: true, schema: { type: string }, description: Discord message snowflake ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this channel's guild }
responses:
'200':
description: Message crossposted.
content:
application/json:
schema:
type: object
properties:
data:
type: object
description: The crossposted Discord message object.
'400': { description: "Invalid ids, or the channel is not an announcement channel." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Discord account not found, not accessible, or not bound to this channel's guild." }
'403': { description: Discord refused the action (bot lacks the required permission). }
'502': { description: Discord was unreachable or returned an unclassified error. }
/v1/discord/channels/{channelId}/threads:
post:
x-resource-group: "engagement"
operationId: createDiscordThread
tags: [Discord]
summary: Create a Discord public thread
description: |
Creates a public thread in a channel. Pass `messageId` to start the thread from an
existing message, or omit it to create a standalone thread.
Threads created here are always public. Requires the bot to hold Create Public
Threads, which the Zernio bot requests at install time.
parameters:
- { name: channelId, in: path, required: true, schema: { type: string }, description: Discord channel snowflake ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of the Discord account bound to this channel's guild }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [name]
properties:
name: { type: string, minLength: 1, maxLength: 100, description: Thread name }
messageId:
type: string
description: Optional message snowflake to start the thread from. Omit for a standalone thread.
autoArchiveDuration:
type: integer
enum: [60, 1440, 4320, 10080]
description: Minutes of inactivity before the thread auto-archives. Discord accepts only these four values.
example:
name: "Release 2.4 discussion"
autoArchiveDuration: 1440
responses:
'200':
description: Thread created.
content:
application/json:
schema:
type: object
properties:
data:
type: object
properties:
id: { type: string, description: Thread snowflake ID }
name: { type: string }
example:
data: { id: "1180000000000000000", name: "Release 2.4 discussion" }
'400': { description: "Invalid accountId, channelId, messageId, name, or autoArchiveDuration." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Discord account not found, not accessible, or not bound to this channel's guild." }
'403': { description: "Discord refused the action (bot lacks Create Public Threads)." }
'502': { description: Discord was unreachable or returned an unclassified error. }
/v1/discord/channels/{channelId}/pins:
get:
x-resource-group: "engagement"
operationId: listDiscordPinnedMessages
tags: [Discord]
summary: List pinned messages
description: |
Returns the channel's pinned messages, sorted most-recently-pinned
first. Discord caps a channel at 50 pinned messages and returns the
full list unpaginated.
Bot needs READ_MESSAGE_HISTORY in the channel (granted by default
BOT_PERMISSIONS).
parameters:
- { name: channelId, in: path, required: true, schema: { type: string }, description: Discord channel snowflake. }
- { name: accountId, in: query, required: true, schema: { type: string }, description: SocialAccount _id of any Discord account in the same guild. }
responses:
'200':
description: Pinned messages.
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
description: Raw Discord Message object. See DiscordPlatformData for embed/attachment shapes.
properties:
id: { type: string }
channel_id: { type: string }
content: { type: string }
timestamp: { type: string, format: date-time }
author: { type: object }
attachments: { type: array, items: { type: object } }
embeds: { type: array, items: { type: object } }
'400': { description: Invalid channelId or accountId format. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found or not accessible. }
'502': { description: Bot lacks access to the channel. }
/v1/discord/channels/{channelId}/pins/{messageId}:
put:
x-resource-group: "engagement"
operationId: pinDiscordMessage
tags: [Discord]
summary: Pin a Discord message
description: |
Pin a specific message in a channel. Path shape mirrors Discord's own
API (`PUT /channels/{cid}/pins/{mid}`).
Idempotent: re-pinning an already-pinned message is a 204 no-op.
Constraints:
- Bot needs MANAGE_MESSAGES in the channel.
- 50-pin cap per channel: hitting it returns 400 (Discord-side).
Caller should unpin one first.
parameters:
- { name: channelId, in: path, required: true, schema: { type: string } }
- { name: messageId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: "Message pinned (or was already pinned, idempotent)."
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
operation: { type: string, enum: [message_pinned] }
channelId: { type: string }
messageId: { type: string }
'400': { description: Validation error or pin cap (50) reached. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found. }
'502': { description: Bot lacks MANAGE_MESSAGES in the channel. }
delete:
x-resource-group: "engagement"
operationId: unpinDiscordMessage
tags: [Discord]
summary: Unpin a Discord message
description: |
Unpin a message. Same MANAGE_MESSAGES permission requirement as pin.
Idempotent: unpinning a non-pinned message is a 204 no-op.
parameters:
- { name: channelId, in: path, required: true, schema: { type: string } }
- { name: messageId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: "Message unpinned (or was not pinned, idempotent)."
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
operation: { type: string, enum: [message_unpinned] }
channelId: { type: string }
messageId: { type: string }
'400': { description: Validation error. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found. }
'502': { description: Bot lacks MANAGE_MESSAGES in the channel. }
/v1/discord/guilds/{guildId}/events:
get:
x-resource-group: "engagement"
operationId: listDiscordScheduledEvents
tags: [Discord]
summary: List Discord scheduled events
description: |
Return all scheduled events in the guild. Events are distinct from
messages: they appear in the server's Events panel and Discord
auto-notifies interested members ahead of start time.
Pass `withUserCount=true` to include `user_count` (number of members
who RSVP'd) on each event. Useful for surfacing engagement.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: withUserCount, in: query, required: false, schema: { type: boolean }, description: Include user_count on each event. }
responses:
'200':
description: List of scheduled events.
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
$ref: '#/components/schemas/DiscordScheduledEvent'
'400': { description: Invalid params. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found or not in this guild. }
'502': { description: Bot lacks access to the guild's events. }
post:
x-resource-group: "engagement"
operationId: createDiscordScheduledEvent
tags: [Discord]
summary: Create a Discord scheduled event
description: |
Create a guild scheduled event. Three event types, selected via the
discriminator on `entity.type`:
- `external`: off-platform (Zoom, in-person, livestream). Requires
both `location` and `endsAt`. Most common type for scheduler
integrations.
- `voice`: hosted in a Discord voice channel. Requires `channelId`.
- `stage`: hosted in a Discord stage channel. Requires `channelId`.
Bot needs MANAGE_EVENTS in the guild. Existing installs (pre-events
PR) need a re-invite OR a server admin manually granting the
permission. See route header for details.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, name, startsAt, entity]
properties:
accountId: { type: string }
name: { type: string, minLength: 1, maxLength: 100 }
description: { type: string, maxLength: 1000 }
startsAt: { type: string, format: date-time, description: ISO 8601 start time. Must be in the future. }
entity:
oneOf:
- type: object
required: [type, location, endsAt]
properties:
type: { type: string, enum: [external] }
location: { type: string, minLength: 1, maxLength: 100, description: "Where the event takes place (e.g. \"Zoom link\", \"123 Main St\")" }
endsAt: { type: string, format: date-time }
- type: object
required: [type, channelId]
properties:
type: { type: string, enum: [voice] }
channelId: { type: string, description: Voice channel snowflake. }
endsAt: { type: string, format: date-time }
- type: object
required: [type, channelId]
properties:
type: { type: string, enum: [stage] }
channelId: { type: string, description: Stage channel snowflake. }
endsAt: { type: string, format: date-time }
imageDataUri:
type: string
pattern: "^data:image/(png|jpeg|gif);base64,"
description: Optional cover image as a base64 data URI.
examples:
external:
value:
accountId: "65a1b2c3d4e5f60718293a4b"
name: "Weekly AMA"
description: "Bring your questions about the roadmap."
startsAt: "2026-06-15T18:00:00Z"
entity: { type: external, location: "https://zoom.us/j/123", endsAt: "2026-06-15T19:00:00Z" }
voice:
value:
accountId: "65a1b2c3d4e5f60718293a4b"
name: "Game Night"
startsAt: "2026-06-20T20:00:00Z"
entity: { type: voice, channelId: "1234567890123456789" }
responses:
'200':
description: Event created.
content:
application/json:
schema:
type: object
properties:
data: { $ref: '#/components/schemas/DiscordScheduledEvent' }
'400': { description: "Validation error (missing required fields for the chosen entity type, malformed snowflake, past startsAt, etc.)." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Discord account not found. }
'502': { description: Bot lacks MANAGE_EVENTS in the guild. }
/v1/discord/guilds/{guildId}/events/{eventId}:
get:
x-resource-group: "engagement"
operationId: getDiscordScheduledEvent
tags: [Discord]
summary: Get a Discord scheduled event
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: eventId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: Event.
content:
application/json:
schema:
type: object
properties:
data: { $ref: '#/components/schemas/DiscordScheduledEvent' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Event or Discord account not found. }
patch:
x-resource-group: "engagement"
operationId: updateDiscordScheduledEvent
tags: [Discord]
summary: Update a Discord scheduled event
description: |
Patch any subset of fields. Passing `status: 'cancelled'` is how you
cancel an event. Discord doesn't have a dedicated cancel endpoint,
it's a status transition.
Most status transitions Discord enforces (you can't go SCHEDULED →
COMPLETED directly). The common consumer case is SCHEDULED → CANCELED.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: eventId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string }
name: { type: string, minLength: 1, maxLength: 100 }
description: { type: string, maxLength: 1000 }
startsAt: { type: string, format: date-time }
endsAt: { type: string, format: date-time }
location: { type: string, minLength: 1, maxLength: 100, description: For external events. }
status: { type: string, enum: [scheduled, active, completed, cancelled], description: "Status transition. Most common: 'cancelled' to cancel an event." }
imageDataUri: { type: string, pattern: "^data:image/(png|jpeg|gif);base64," }
example:
accountId: "65a1b2c3d4e5f60718293a4b"
status: "cancelled"
responses:
'200':
description: Event updated.
content:
application/json:
schema:
type: object
properties:
data: { $ref: '#/components/schemas/DiscordScheduledEvent' }
'400': { description: "Validation error, no updatable fields beyond accountId provided, or Discord rejected the update (invalid status transition)." }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Discord refused the update (bot permissions). }
'404': { description: Event or Discord account not found. }
'502': { description: Discord was unreachable or returned an unclassified error. }
delete:
x-resource-group: "engagement"
operationId: deleteDiscordScheduledEvent
tags: [Discord]
summary: Delete a Discord scheduled event
description: |
Hard-delete an event. Use PATCH with `status: 'cancelled'` instead
if you want the event preserved in the guild's history.
parameters:
- { name: guildId, in: path, required: true, schema: { type: string } }
- { name: eventId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: Event deleted.
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
deleted: { type: string, description: The deleted event's snowflake. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Event or Discord account not found. }
'502': { description: Bot lacks MANAGE_EVENTS in the guild. }
/v1/queue/slots:
get:
x-resource-group: "publishing"
operationId: listQueueSlots
tags: [Queue]
summary: List schedules
description: Returns queue schedules for a profile. Use all=true for all queues, or queueId for a specific one. Defaults to the default queue.
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
description: Profile ID to get queues for
- name: queueId
in: query
required: false
schema: { type: string }
description: Specific queue ID to retrieve (optional)
- name: all
in: query
required: false
schema: { type: string, enum: ['true', 'false'] }
description: Set to 'true' to list all queues for the profile
responses:
'200':
description: Queue schedule(s) retrieved
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/QueueSlotsResponse'
- type: object
description: All queues response (when all=true)
properties:
queues:
type: array
items:
$ref: '#/components/schemas/QueueSchedule'
count: { type: integer }
examples:
singleQueue:
summary: Single queue response
value:
exists: true
schedule:
_id: "64f0a1b2c3d4e5f6a7b8c9d1"
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
name: "Morning Posts"
timezone: "America/New_York"
slots:
- dayOfWeek: 1
time: "09:00"
- dayOfWeek: 3
time: "09:00"
- dayOfWeek: 5
time: "10:00"
active: true
isDefault: true
nextSlots:
- "2024-11-04T09:00:00-05:00"
- "2024-11-06T09:00:00-05:00"
allQueues:
summary: All queues response (all=true)
value:
queues:
- _id: "64f0a1b2c3d4e5f6a7b8c9d1"
name: "Morning Posts"
isDefault: true
timezone: "America/New_York"
slots: [{ dayOfWeek: 1, time: "09:00" }]
active: true
- _id: "64f0a1b2c3d4e5f6a7b8c9d2"
name: "Evening Content"
isDefault: false
timezone: "America/New_York"
slots: [{ dayOfWeek: 1, time: "18:00" }]
active: true
count: 2
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Profile not found }
post:
x-resource-group: "publishing"
operationId: createQueueSlot
tags: [Queue]
summary: Create schedule
description: |
Create an additional queue for a profile. The first queue created becomes the default.
Subsequent queues are non-default unless explicitly set.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, name, timezone, slots]
properties:
profileId: { type: string, description: Profile ID }
name: { type: string, description: "Queue name (e.g., Evening Posts)" }
timezone: { type: string, description: IANA timezone }
slots:
type: array
items:
$ref: '#/components/schemas/QueueSlot'
active: { type: boolean, default: true }
example:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
name: "Evening Posts"
timezone: "America/New_York"
slots:
- dayOfWeek: 1
time: "18:00"
- dayOfWeek: 3
time: "18:00"
- dayOfWeek: 5
time: "18:00"
active: true
responses:
'201':
description: Queue created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
schedule:
$ref: '#/components/schemas/QueueSchedule'
nextSlots:
type: array
items: { type: string, format: date-time }
'400': { description: Invalid request or validation error }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Profile not found }
put:
x-resource-group: "publishing"
operationId: updateQueueSlot
tags: [Queue]
summary: Update schedule
description: |
Create a new queue or update an existing one. Without queueId, creates/updates the default queue. With queueId, updates a specific queue. With setAsDefault=true, makes this queue the default for the profile.
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, timezone, slots]
properties:
profileId: { type: string }
queueId: { type: string, description: Queue ID to update (optional) }
name: { type: string, description: Queue name }
timezone: { type: string }
slots:
type: array
items:
$ref: '#/components/schemas/QueueSlot'
active: { type: boolean, default: true }
setAsDefault: { type: boolean, description: Make this queue the default }
reshuffleExisting:
type: boolean
default: false
description: Whether to reschedule existing queued posts to match new slots
example:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
queueId: "64f0a1b2c3d4e5f6a7b8c9d1"
name: "Morning Posts"
timezone: "America/New_York"
slots:
- dayOfWeek: 1
time: "09:00"
- dayOfWeek: 3
time: "09:00"
- dayOfWeek: 5
time: "10:00"
active: true
setAsDefault: false
responses:
'200':
description: Queue schedule updated
content:
application/json:
schema:
$ref: '#/components/schemas/QueueUpdateResponse'
example:
success: true
schedule:
_id: "64f0a1b2c3d4e5f6a7b8c9d1"
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
name: "Morning Posts"
timezone: "America/New_York"
slots:
- dayOfWeek: 1
time: "09:00"
- dayOfWeek: 3
time: "09:00"
- dayOfWeek: 5
time: "10:00"
active: true
isDefault: true
nextSlots:
- "2024-11-04T09:00:00-05:00"
- "2024-11-06T09:00:00-05:00"
reshuffledCount: 0
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Profile not found }
delete:
x-resource-group: "publishing"
operationId: deleteQueueSlot
tags: [Queue]
summary: Delete schedule
description: |
Delete a queue from a profile. Pass queueId to delete a specific queue;
omit it to delete all queues for the profile.
If deleting the default queue, another queue will be promoted to default.
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
- name: queueId
in: query
required: false
schema: { type: string }
description: Queue ID to delete. Omit to delete all queues for the profile
responses:
'200':
description: Queue schedule deleted
content:
application/json:
schema:
$ref: '#/components/schemas/QueueDeleteResponse'
example:
success: true
deleted: true
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Profile or queue not found }
/v1/queue/preview:
get:
x-resource-group: "publishing"
operationId: previewQueue
tags: [Queue]
summary: Preview upcoming slots
description: Returns the next N upcoming queue slot times for a profile as ISO datetime strings.
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
- name: queueId
in: query
schema: { type: string }
description: Filter by specific queue ID. Omit to use the default queue.
- name: count
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 20 }
responses:
'200':
description: Queue slots preview
content:
application/json:
schema:
$ref: '#/components/schemas/QueuePreviewResponse'
example:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
count: 10
slots:
- "2024-11-04T09:00:00-05:00"
- "2024-11-04T14:00:00-05:00"
- "2024-11-06T09:00:00-05:00"
- "2024-11-08T10:00:00-05:00"
- "2024-11-11T09:00:00-05:00"
- "2024-11-11T14:00:00-05:00"
- "2024-11-13T09:00:00-05:00"
- "2024-11-15T10:00:00-05:00"
- "2024-11-18T09:00:00-05:00"
- "2024-11-18T14:00:00-05:00"
'400': { description: Invalid parameters }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Profile or queue schedule not found }
/v1/queue/next-slot:
get:
x-resource-group: "publishing"
operationId: getNextQueueSlot
tags: [Queue]
summary: Get next available slot
description: Returns the next available queue slot for preview purposes. To create a queue post, use POST /v1/posts with queuedFromProfile instead of scheduledFor.
parameters:
- name: profileId
in: query
required: true
schema: { type: string }
- name: queueId
in: query
required: false
schema: { type: string }
description: Specific queue ID (optional, defaults to profile's default queue)
responses:
'200':
description: Next available slot
content:
application/json:
schema:
$ref: '#/components/schemas/QueueNextSlotResponse'
example:
profileId: "64f0a1b2c3d4e5f6a7b8c9d0"
nextSlot: "2024-11-04T09:00:00-05:00"
timezone: "America/New_York"
queueId: "64f0a1b2c3d4e5f6a7b8c9d1"
queueName: "Morning Posts"
'400':
description: Invalid parameters or inactive queue
'401': { $ref: '#/components/responses/Unauthorized' }
'404':
description: "Profile or queue schedule not found, or no available slots"
# ============================================
# Webhooks API (Multi-Webhook System)
# ============================================
/v1/webhooks/settings:
get:
x-resource-group: "webhooks"
operationId: getWebhookSettings
tags: [Webhooks]
summary: List webhooks
description: Retrieve all configured webhooks for the authenticated user. Supports up to 50 webhooks per user.
security:
- bearerAuth: []
responses:
'200':
description: Webhooks retrieved successfully
content:
application/json:
schema:
type: object
properties:
webhooks:
type: array
items:
$ref: '#/components/schemas/Webhook'
example:
webhooks:
- _id: "507f1f77bcf86cd799439011"
name: "My Production Webhook"
url: "https://example.com/webhook"
events: ["post.published", "post.failed"]
isActive: true
lastFiredAt: "2024-01-15T10:30:00Z"
failureCount: 0
- _id: "507f1f77bcf86cd799439012"
name: "Slack Notifications"
url: "https://hooks.slack.com/services/xxx"
events: ["post.failed", "account.disconnected"]
isActive: true
failureCount: 0
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/ResourceGroupForbidden' }
post:
x-resource-group: "webhooks"
operationId: createWebhookSettings
tags: [Webhooks]
summary: Create webhook
description: |
Create a new webhook configuration. Maximum 50 webhooks per user.
`name`, `url` and `events` are required. `url` must be a valid URL and `events` must contain at least one event. Whitespace is trimmed from `url` before validation.
Webhooks are 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`.
A restricted (zrk_) API key can only subscribe to events whose resource group
the key holds; an event outside the key's groups is rejected with 403, so a
restricted key can never create a subscription broader than itself.
`disabledResourceGroups` restricts the subscription itself, independently of
which key or session later reads it. Events in a disabled group are dropped
before delivery to this endpoint, on live delivery and on every replay path
(test fire, redelivery, dead-letter requeue), even if they are listed in
`events`. Omit it to receive everything in `events`, which is how existing
subscriptions behave. A restricted key's own disabled groups are always
unioned in.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- name
- url
- events
properties:
name:
type: string
description: Webhook name (1-50 characters)
minLength: 1
maxLength: 50
url:
type: string
format: uri
description: Webhook endpoint URL (must be a valid URL, whitespace trimmed)
secret:
type: string
description: Secret key for HMAC-SHA256 signature verification
events:
type: array
minItems: 1
items:
type: string
enum: [post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled, post.platform.published, post.platform.failed, post.platform.deleted, post.tiktok.url_resolved, post.external.created, post.external.updated, post.external.deleted, account.connected, account.disconnected, account.ads.initial_sync_completed, 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.
When provided, `name` must be 1-50 characters, `url` must be a valid URL, and `events` must contain at least one event. Whitespace is trimmed from `url` before validation.
Webhooks are 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`.
A restricted (zrk_) API key can only set `events` to events whose resource
group the key holds; an event outside the key's groups is rejected with 403.
It also cannot widen an existing subscription past its own groups.
`disabledResourceGroups` replaces the subscription's own denylist, which
applies to delivery regardless of which key or session created it. Send an
empty array to clear it. A restricted key's own disabled groups are unioned
into the stored value on every update, so repointing a legacy unrestricted
subscription with a restricted key also narrows it.
Timing: the new denylist applies to every event emitted after the update.
Events already queued for delivery when the update landed were filtered
against the previous denylist and can still arrive at your endpoint for up
to five minutes after they were enqueued, because the delivery worker
trusts a five-minute enqueue-time snapshot before re-checking the
subscription. Retries beyond that window, dead-letter replays, test fires,
and redeliveries are all checked against the current denylist.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- _id
properties:
_id:
type: string
description: Webhook ID to update (required)
name:
type: string
description: Webhook name (1-50 characters). Must be non-empty if provided.
minLength: 1
maxLength: 50
url:
type: string
format: uri
description: Webhook endpoint URL (must be a valid URL, whitespace trimmed). Must be a valid URL if provided.
secret:
type: string
description: Secret key for HMAC-SHA256 signature verification
events:
type: array
minItems: 1
items:
type: string
enum: [post.scheduled, post.published, post.failed, post.partial, post.cancelled, post.recycled, post.platform.published, post.platform.failed, post.platform.deleted, post.tiktok.url_resolved, post.external.created, post.external.updated, post.external.deleted, account.connected, account.disconnected, account.ads.initial_sync_completed, 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.
Logs are retained for 30 days. Supports filtering by status, event type, webhook ID, and event ID,
plus offset-based pagination.
For a restricted (zrk_) API key, rows for events outside the key's resource
groups are omitted (`pagination.total` may over-count), and an `event` filter
naming such an event is rejected with 403. Events blocked by a subscription's
own `disabledResourceGroups` are dropped before delivery, so they produce no
log rows for anyone; the exception is the five-minute tail after a denylist
change, where an already-queued event can still be delivered and logged.
security:
- bearerAuth: []
parameters:
- name: limit
in: query
description: Maximum number of logs to return
schema:
type: integer
minimum: 1
maximum: 100
default: 50
- name: skip
in: query
description: Number of logs to skip (offset-based pagination)
schema:
type: integer
minimum: 0
maximum: 10000
default: 0
- name: status
in: query
description: Filter by delivery outcome
schema:
type: string
enum: [success, failed]
- name: event
in: query
description: Filter by event type (e.g. post.published)
schema:
type: string
minLength: 1
maxLength: 100
- name: webhookId
in: query
description: Filter by webhook configuration ID
schema:
type: string
minLength: 1
maxLength: 100
- name: eventId
in: query
description: Filter by stable webhook event ID
schema:
type: string
minLength: 1
maxLength: 200
responses:
'200':
description: Webhook logs retrieved successfully
content:
application/json:
schema:
type: object
properties:
logs:
type: array
items:
$ref: '#/components/schemas/WebhookLog'
pagination:
type: object
properties:
total:
type: integer
description: Total number of matching logs
limit:
type: integer
description: Maximum number of logs returned per page
skip:
type: integer
description: Number of logs skipped
pages:
type: integer
description: Total number of pages
hasMore:
type: boolean
description: Whether more logs are available beyond this page
'400': { description: Invalid query parameter }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/ResourceGroupForbidden' }
/v1/webhooks/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
subscription's current URL. The original event ID is preserved so your endpoint can
dedupe, and the replay is recorded as a fresh attempt, so it shows up in
`GET /v1/webhooks/logs` next to the delivery it replays.
Both `webhookId` and `eventId` come from a row of `GET /v1/webhooks/logs`. Because
the stored payload is replayed as-is, a redelivery reflects the event as it was
emitted, not the current state of the resource.
Only deliveries inside the 30-day log retention window can be replayed; past that
the payload is gone and the request fails with a 422. Replays run the same
resource-group checks as live delivery, against both the key's groups and the
subscription's `disabledResourceGroups`.
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
logged; `message` describes the failure.
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.
`webhook.test` belongs to the `webhooks` resource group, so a key with that
group disabled is rejected with 403, as is a test fire on a subscription that
lists `webhooks` in its own `disabledResourceGroups` (a 403, not a reported
delivery failure). Replays of real events (redelivery, dead-letter requeue) run
the same checks as live delivery, against both the key's groups and the
subscription's.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- webhookId
properties:
webhookId:
type: string
description: ID of the webhook to test
example:
webhookId: "507f1f77bcf86cd799439011"
responses:
'200':
description: Test webhook sent successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
example:
success: true
message: "Test webhook sent successfully"
'400': { description: Webhook ID required }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { $ref: '#/components/responses/ResourceGroupForbidden' }
'404': { description: Webhook not found }
'500':
description: Test webhook failed to deliver
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
example:
success: false
message: "Test webhook failed"
/v1/logs:
get:
x-resource-group: "publishing"
operationId: listLogs
tags: [Logs]
summary: List activity logs
description: |
Unified logs endpoint. Returns logs for publishing, connections, webhooks, and messaging.
Filter by type, platform, status, and time range. Logs are retained for 90 days.
security:
- bearerAuth: []
parameters:
- name: type
in: query
description: |
Log category to query. Use `all` for the unified view across every category,
or `api_request` for your API request logs (method, path, status, latency).
schema:
type: string
enum: [all, publishing, connections, webhooks, messaging, workflow_event, api_request]
default: publishing
- name: status
in: query
description: Filter by status
schema:
type: string
enum: [success, failed, pending, skipped, all]
- name: platform
in: query
description: Filter by platform
schema:
type: string
enum: [tiktok, instagram, whatsapp, facebook, youtube, linkedin, twitter, threads, pinterest, reddit, bluesky, googlebusiness, telegram, snapchat, all]
- name: action
in: query
description: Filter by action (e.g., post.published, message.sent, account.connected, webhook.delivered)
schema:
type: string
- name: search
in: query
description: Free-text search across log fields
schema:
type: string
- name: days
in: query
description: Number of days to look back (max 90)
schema:
type: integer
minimum: 1
maximum: 90
default: 90
- name: limit
in: query
description: Maximum number of logs to return (max 100)
schema:
type: integer
minimum: 1
maximum: 100
default: 50
- name: skip
in: query
description: Number of logs to skip (for pagination)
schema:
type: integer
minimum: 0
default: 0
- name: account_id
in: query
description: Filter by connected account ID
schema:
type: string
- name: event
in: query
description: Filter webhook logs by event (e.g. post.published, message.received)
schema:
type: string
- name: request_id
in: query
description: Correlation ID. Returns every log spawned by a single API request
schema:
type: string
- name: from
in: query
description: Precise start instant (ISO 8601); narrows within the day range
schema:
type: string
format: date-time
- name: to
in: query
description: Precise end instant (ISO 8601)
schema:
type: string
format: date-time
- name: status_code
in: query
description: Filter by exact HTTP status code (api_request logs)
schema:
type: integer
- name: api_key_id
in: query
description: Filter by the API key that made the request (api_request logs)
schema:
type: string
- name: include_read_receipts
in: query
description: Include message.read / message.delivered events (hidden by default for messaging logs)
schema:
type: boolean
default: false
responses:
'200':
description: Logs retrieved successfully
content:
application/json:
schema:
type: object
properties:
logs:
type: array
items:
type: object
properties:
type:
type: string
description: Log category (publishing, connections, webhooks, messaging)
action:
type: string
description: Specific action (post.published, message.sent, account.connected, etc.)
user_id:
type: string
platform:
type: string
account_id:
type: string
status:
type: string
enum: [success, failed, pending, skipped]
status_code:
type: integer
error_message:
type: string
error_code:
type: string
duration_ms:
type: integer
endpoint:
type: string
description: The API endpoint that triggered this log
request_body:
type: string
description: Request JSON (truncated to 5KB)
response_body:
type: string
description: Response JSON (truncated to 10KB)
created_at:
type: string
format: date-time
metadata:
type: string
description: Additional context as JSON string
request_id:
type: string
description: Correlation ID linking every log from one API request (api_request logs)
api_key_id:
type: string
description: The API key that made the request (api_request logs)
method:
type: string
description: HTTP method (api_request logs)
path:
type: string
description: Request path (api_request logs)
ip_address:
type: string
description: Client IP address (api_request logs)
user_agent:
type: string
description: Client user-agent (api_request logs)
pagination:
type: object
properties:
total:
type: integer
limit:
type: integer
skip:
type: integer
pages:
type: integer
hasMore:
type: boolean
'401': { $ref: '#/components/responses/Unauthorized' }
# Unified Inbox Endpoints
/v1/inbox/conversations:
get:
x-resource-group: "messages"
operationId: listInboxConversations
summary: List conversations
description: |
Fetch conversations (DMs) from all connected messaging accounts in a single API call. Supports filtering by profile and platform. Results are aggregated and deduplicated.
Supported platforms: Facebook, Instagram, X, Bluesky, Reddit, Telegram.
**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.
**Instagram and Facebook pre-connect history.** When one of these accounts is connected, Zernio replays the DM history the account already holds on Meta, so conversations that began before the account was connected appear here. Up to 500 conversations per account are replayed.
- The replay runs in the background and can finish after a listing you have already taken, and replayed conversations keep their original lastMessageAt, so they sort into date order rather than appearing at the top. If you mirror this endpoint into your own store, re-run the sweep rather than relying on a single pass at connect time.
- Replayed history emits no webhooks and is stored as already read, so it never affects unread counts.
- Threads that Meta refuses to serve are skipped, and an account whose Instagram "connected tools" message access is turned off is not replayed at all.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID
- name: platform
in: query
schema: { type: string, enum: [facebook, instagram, twitter, bluesky, reddit, telegram, whatsapp] }
description: Filter by platform
- name: status
in: query
schema: { type: string, enum: [active, archived] }
description: Filter by conversation status
- name: sortOrder
in: query
schema: { type: string, enum: [asc, desc], default: desc }
description: Sort order by updated time
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 50 }
description: Maximum number of conversations to return
- name: cursor
in: query
schema: { type: string }
description: Pagination cursor for next page
- name: accountId
in: query
schema: { type: string }
description: Filter by specific 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
a ref-tagged ig.me / m.me link. Absent when the conversation did not
originate from an attributable click.
Captured from the referral Meta delivers for the click. If the same
person later arrives through a different ad or link, the original
values are kept, so the first referral wins; read the fresh referral
per click on the `message.received` / `referral.received` webhooks
instead. One exception on WhatsApp: when Meta omits `ctwa_clid`
from that referral, a later Meta automatic event can supply it and
refresh `ctwa_captured_at`, so treat `ctwa_captured_at` as the time
Zernio stored the value, not the time of the click.
Two families of keys, one per surface. They never appear together:
- `ctwa_*` is WhatsApp Click-to-WhatsApp. The ad ID is
`ctwa_source_id`. There is no `meta_ad_id` on WhatsApp.
- `meta_ad_*` is Instagram Click-to-Direct, Facebook Messenger
Click-to-Message, and ig.me / m.me ref links. The ad ID is
`meta_ad_id` (ad clicks only; a link capture carries
`meta_ad_ref` without it). `ctwa_clid` never appears on these
platforms.
Every key is optional and only the keys Meta supplied are returned, so
read defensively. Meta does not send a campaign or ad set ID, so none
is exposed here. More keys may be added over time. Treat any key you
do not recognise as an opaque string.
Key names differ from the `message.received` webhook on purpose. The
webhook forwards Meta's referral verbatim (`ad_id`, `source`, `type`)
while the stored conversation record uses the prefixed names below.
Renaming either side would break existing integrations, so both
spellings are kept.
properties:
ctwa_clid:
type: string
description: "WhatsApp only. Meta's click identifier, the value to forward to the Meta Conversions API for Business Messaging. Meta omits it on some numbers, so a WhatsApp referral can arrive without it."
ctwa_source_id:
type: string
description: "WhatsApp only. The Meta ad ID the user clicked. This is the WhatsApp equivalent of meta_ad_id."
ctwa_source_type:
type: string
description: "WhatsApp only. What the user clicked, as supplied by Meta (for example ad or post)."
ctwa_source_url:
type: string
description: "WhatsApp only. Meta's URL for the ad that was clicked, normally an fb.me short link."
ctwa_headline:
type: string
description: "WhatsApp only. Headline of the ad creative at click time."
ctwa_captured_at:
type: string
format: date-time
description: "WhatsApp only. When Zernio stored this referral. Always present when a WhatsApp referral was captured."
meta_ad_id:
type: string
description: "Instagram and Facebook only. The Meta ad ID the user clicked. 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.
Supported platforms: X, Bluesky, Reddit, WhatsApp, SMS, and Slack. Other platforms return PLATFORM_NOT_SUPPORTED.
**Slack.** Pass a workspace member id as participantId (list them with GET /v1/accounts/{accountId}/slack-members). Zernio opens the DM channel with that member and sends the message; the thread then behaves like any other Slack conversation in the inbox. The member must belong to the connected workspace.
**WhatsApp.** This is the endpoint for sending an approved template message to a phone number. Provide templateName, templateLanguage, and templateParams (variable values for the text header, body and dynamic URL buttons, in that order), with the recipient phone in participantId. A template is required because WhatsApp does not permit freeform messages to open a conversation; a missing template returns TEMPLATE_REQUIRED.
Before 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.
- 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.
- 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.
- 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.
- 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.
- Template fields are accepted on the JSON body only, not on multipart requests.
For 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).
Alternatively, WhatsApp Business Accounts eligible for Meta Direct Send can open a conversation with a business-initiated utility text message and no template: pass category: 'utility' together with message (and no templateName). See the category field below.
**DM eligibility (X).** Before sending, the endpoint checks if the recipient accepts DMs from your account (via the receives_your_dm field). If not, a 422 error with code DM_NOT_ALLOWED is returned. You can skip this check with skipDmCheck: true if you have already verified eligibility.
**X API tier requirement.** DM write endpoints require X API Pro tier ($5,000/month) or Enterprise access. This applies to BYOK (Bring Your Own Key) users who provide their own X API credentials.
**Rate limits (X only).** X's DM API enforces 200 requests per 15 minutes, 1,000 per 24 hours per connected X account, and 15,000 per 24 hours per X developer app (shared across all DM endpoints). These limits do NOT apply to other platforms. WhatsApp sends are governed by Meta's per-number messaging tiers (unique business-initiated conversations per 24 hours) and per-number throughput instead.
tags: [Messages]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId:
type: string
description: The 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:
- Message text: matches words inside message bodies. Case-insensitive and accent-insensitive, exact tokens only (no substrings, no stemming). Each hit carries up to 3 most-recent matching messages. With direction=outgoing you can collect examples of how you write to customers, for example to teach an AI agent your tone of voice.
- Contact identity: matches the participant's name, username, or phone number as a case-insensitive substring. These hits have matchCount 0 and an empty matches array.
A conversation that matches both ways is returned once, carrying its message matches.
Only platforms whose messages are stored by Zernio are searchable: WhatsApp, SMS, Telegram, Facebook, Instagram, X and Reddit. Bluesky conversations are fetched live from the platform and cannot be searched; those accounts are listed in meta.accountsSkipped.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: query
in: query
required: true
schema: { type: string, minLength: 2, maxLength: 200 }
description: "Text to search for, in message content and in the contact's name, username, or phone number"
- name: direction
in: query
schema: { type: string, enum: [incoming, outgoing] }
description: "Only match messages sent to you (incoming) or by you (outgoing). Contact-identity matching is not applied when this is set."
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID
- name: platform
in: query
schema: { type: string, enum: [facebook, instagram, telegram, whatsapp, sms, slack] }
description: Filter by platform (searchable platforms only)
- name: accountId
in: query
schema: { type: string }
description: Filter by specific 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.
Absent when the conversation did not originate from an ad click.
Captured once, on the first inbound message after the click, and never
overwritten. If the same person later clicks a different ad, the
original values are kept. Meta only sends the referral on that first
message.
This operation currently returns only the `meta_ad_*` family, which
covers Instagram Click-to-Direct and Facebook Messenger
Click-to-Message. WhatsApp Click-to-WhatsApp attribution (the `ctwa_*`
keys, where the ad ID is `ctwa_source_id`) is returned by
`GET /v1/inbox/conversations` instead.
Every key is optional and only the keys Meta supplied are returned, so
read defensively. Meta does not send a campaign or ad set ID, so none is
exposed here. More keys may be added over time. Treat any key you do not
recognise as an opaque string.
Key names differ from the `message.received` webhook on purpose. The
webhook forwards Meta's referral verbatim (`ad_id`, `source`, `type`)
while the stored conversation record uses the prefixed names below.
Renaming either side would break existing integrations, so both
spellings are kept.
properties:
meta_ad_id:
type: string
description: "The Meta ad ID the user clicked. Always present when a referral was captured."
meta_ad_source:
type: string
description: "Meta-supplied source identifier, for example ADS."
meta_ad_type:
type: string
description: "Meta-supplied referral type, for example OPEN_THREAD."
meta_ad_ref:
type: string
description: "The ref parameter passed through from the ad creative."
meta_ad_title:
type: string
description: "Title of the ad creative at click time."
meta_ad_photo_url:
type: string
description: "Image of the ad creative at click time."
meta_ad_video_url:
type: string
description: "Video of the ad creative at click time."
meta_ad_post_id:
type: string
description: "The organic post the ad promoted, when the ad was a boosted post."
meta_ad_product_id:
type: string
description: "The catalogue product the user clicked, for product ads."
meta_ad_flow_id:
type: string
description: "The Meta flow the ad launched, for flow ads."
meta_ad_captured_at:
type: string
format: date-time
description: "When Zernio stored this referral. Always present when a referral was captured."
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404':
description: Conversation not found
put:
x-resource-group: "messages"
operationId: updateInboxConversation
summary: Update conversation status
description: Archive or activate a conversation. Requires accountId in request body.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: '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
and ordering control.
Pagination: pass `pagination.nextCursor` from a prior response back as
the `cursor` query param to fetch the next page. The cursor is opaque;
do not parse or construct it client-side.
Sort order: defaults to `asc` (oldest first, chat style). For the
"show me the latest messages" pattern, pass `?sortOrder=desc&limit=N`.
X, Instagram, Telegram, WhatsApp and Reddit honor the requested
order from the local message store. For Facebook and Bluesky, the
upstream APIs only return newest-first and have no order parameter, so
sort order is best-effort and only reverses items within a single page
(pages still walk newest→oldest). The response field `sortOrderApplied`
tells you what was actually applied.
Reddit threads are paginated client-side because Reddit's API has no
per-thread cursor. Very long threads may be upstream-truncated by
Reddit's inbox/sent windows (~100 most-recent items each); this is a
Reddit platform limitation.
Instagram and Facebook conversations include history from before the
account was connected, replayed from Meta. That replay covers the 500
most recent messages per conversation: a longer thread keeps its newest
500 and older messages are not retrievable. Messages that arrived after
the account was connected are unaffected. Replayed messages are stored
as already read and emit no webhooks.
X limitation: X's encrypted "X Chat" messages are not accessible via the API. Conversations where the other participant uses encrypted X Chat may only show your outgoing messages. See the list conversations endpoint for more details.
This endpoint is read-only and does NOT mark messages as read or send
read receipts. To mark a conversation read (and send WhatsApp blue ticks
on eligible accounts), call `POST /v1/inbox/conversations/{conversationId}/read`.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: '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).
X, Instagram, Telegram, WhatsApp and Reddit honor this order
across cursor pages. For Facebook and Bluesky, only intra-page
ordering is affected. Pages always walk newest→oldest. See
`sortOrderApplied` in the response.
responses:
'200':
description: Messages in conversation
content:
application/json:
schema:
type: object
properties:
status: { type: string }
pagination:
type: object
properties:
hasMore:
type: boolean
description: Whether more messages are available beyond this page.
nextCursor:
type: [string, "null"]
description: Opaque cursor to fetch the next page. `null` on the last page.
sortOrderApplied:
type: string
enum: [asc, desc]
description: |
Sort order actually applied to the returned page. May
differ from the requested `sortOrder` for Facebook and
Bluesky (always `desc` regardless of request).
messages:
type: array
items:
type: object
properties:
id:
type: string
description: |
The platform's own message id: the `wamid` on WhatsApp, the
`mid` on Instagram and Facebook Messenger. This is what
`metadata.quotedMessageId` points at, the value to pass as
`replyTo` on the platforms that support quote-replies, and the
`{messageId}` segment of the attachment-resolve URL. Webhooks
deliver the same value as `message.platformMessageId`; this
response has no field by that name.
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 }
# ─── Lifecycle state (edits, deletes, delivery) ──────────────
# Populated by webhook events from the platforms that support
# them. See the support matrix in the Webhooks description
# above. Deleted messages retain their original message and
# attachments. The Zernio dashboard hides this content, but
# it is available here for moderation/compliance use cases.
isEdited:
type: boolean
description: True if the sender has edited this message at least once.
editedAt:
type: [string, "null"]
format: date-time
description: When the most recent edit happened.
editCount:
type: integer
description: Total number of edits applied.
editHistory:
type: array
description: Every prior version of the message, oldest first.
items:
$ref: '#/components/schemas/InboxMessageEditHistoryEntry'
isDeleted:
type: boolean
description: True if the sender has deleted (unsent) this message. The original message and attachments fields remain populated.
deletedAt:
type: [string, "null"]
format: date-time
deliveryStatus:
type: [string, "null"]
enum: [sent, delivered, read, failed, deleted]
description: Lifecycle status for outgoing messages. Not all platforms emit every state (see webhook support matrix).
deliveredAt:
type: [string, "null"]
format: date-time
readAt:
type: [string, "null"]
format: date-time
sentAt:
type: [string, "null"]
format: date-time
description: Original send time for outgoing messages (used for Messenger watermark queries).
deliveryError:
type: [object, "null"]
description: Populated when deliveryStatus === "failed".
properties:
code: { type: integer }
title: { type: string }
message: { type: string }
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:
`quotedMessageId` (the `id` of the message this one replies to,
delivered as `message.platformMessageId` on webhooks),
`waInteractive` (a compact descriptor of WhatsApp interactive
content sent: buttons / list / cta_url / flow / location_request),
and for inbound interactive taps `interactiveType` / `interactiveId`.
It can also carry `source` (`whatsapp_business_app` /
`coexistence_history` on a WhatsApp Coexistence number, `bulk-api` on
a POST /v1/whatsapp/bulk send), which is where the message reached us
from rather than who produced it: read `sentVia` for that.
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
operator in the Zernio inbox), `api` (a call to this API),
`broadcast`, `sequence`, `workflow`, `comment_automation`, or
`bulk-api` (POST /v1/whatsapp/bulk). Same vocabulary as the `source`
filter on the inbox analytics endpoints.
Always present, and `null` whenever the lineage is unknown: every
incoming message, any outgoing message sent from the platform's own
app, and every message stored before this field shipped
(2026-08). Existing messages are NOT backfilled, so treat `null`
as "unknown", never as "sent by a human".
lastUpdated: { type: string, format: date-time }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'503': { $ref: '#/components/responses/TemporarilyUnavailable' }
'502': { $ref: '#/components/responses/UpstreamFailure' }
post:
x-resource-group: "messages"
operationId: sendInboxMessage
summary: Send message
description: |
Send a message in a conversation. Supports text, attachments, quick replies,
buttons, templates, and message tags. Attachment and interactive message
support varies by platform.
WhatsApp per-recipient rate limit: WhatsApp caps how many messages you may
send to the same recipient in a short window and rejects the excess with
error code `131056` ("Too many messages sent to this recipient"). Pace
sends to a single recipient at roughly 10 per minute; bursts above that
return a `400` with code `131056`. Sends to other recipients are
unaffected, so parallelise across recipients rather than flooding one.
WhatsApp template messages: to send an approved template into this
conversation (required when the 24-hour customer-service window is
closed), use the `template` field with a single element carrying the
template reference: `{ "elements": [{ "name": ..., "language": ..., "components": [...] }] }`.
See the `template` field below for the exact shape. To send a template
to a phone number you have no conversation with yet, use the
create-conversation endpoint (POST /v1/inbox/conversations) instead.
Zernio resolves the exact APPROVED template name and language before any
WhatsApp template send. A failed lookup or missing exact definition sends
no message and returns 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.
WhatsApp rich interactive messages (list, CTA URL, Flow, location request)
are available via the `interactive` field. Tap events are delivered through
the `message.received` webhook with WhatsApp-specific `metadata` fields
(`interactiveType`, `interactiveId`, `flowResponseJson`, `flowResponseData`).
**Idempotency:** send an `Idempotency-Key` header to make retries safe
(e.g. after a client-side timeout where delivery is unknown): same key +
same body replays the original response (with `Idempotent-Replayed: true`)
instead of sending the message a second time; same key + different body
returns 422; a key still in flight returns 409. Works for JSON and
multipart (file upload) requests alike. Keys are retained for 24 hours.
Only successful (2xx) responses are stored for replay: if the request
throws or returns a non-2xx status, the key is released so the same key
can be retried once the problem is fixed. The header therefore protects
the "request succeeded but the response was lost" case. For an ambiguous
failure (a 5xx or a network timeout), reconcile before retrying: a
failure after the platform already accepted the message also releases
the key, and a blind retry could send it twice. List the conversation's
messages first, and treat an empty result as inconclusive rather than
as proof nothing was sent, since a send that failed while being recorded
leaves no trace on our side.
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
as a voice message (PTT): the recipient sees the waveform + voice-note
UI instead of a basic audio attachment. The audio file MUST be `.ogg`
encoded with the OPUS codec (mono) per Meta's voice-message contract;
other formats are rejected by WhatsApp. Ignored for non-audio attachments.
quickReplies:
type: array
maxItems: 13
description: Quick reply buttons. Mutually exclusive with buttons. Max 13 items.
items:
type: object
required: [title, payload]
properties:
title: { type: string, maxLength: 20, description: Button label (max 20 chars) }
payload: { type: string, description: Payload sent back on tap }
imageUrl: { type: string, description: Optional icon URL (Meta only) }
buttons:
type: array
maxItems: 3
description: |
Action buttons. Mutually exclusive with quickReplies. Max 3 items.
Instagram / Facebook: also mutually exclusive with `template`.
A Meta message carries one body shape, so sending both is a 400
rather than a silent drop of the buttons.
WhatsApp: buttons always render as interactive reply buttons.
Only `title` and `payload` are used; `type`, `url`, and `phone`
are ignored (WhatsApp has no URL/phone button in this field; use
the `interactive` field with `type: cta_url` for a link button).
`payload` becomes the button reply ID delivered on the
`message.received` webhook when the user taps. To send a simple
reply-button message, provide `title` + `payload` and set
`type: postback`, e.g.
`{ "type": "postback", "title": "Yes", "payload": "yes" }`.
Combine `buttons` with `attachmentUrl` and `attachmentType`
`image`, `video`, or `file` to render one WhatsApp message with
a media header, body text, and reply buttons. Audio is not a
supported interactive header and returns 400 when combined
with buttons.
items:
type: object
required: [type, title]
properties:
type: { type: string, enum: [url, postback, phone], description: "Button type. phone is Facebook only. Ignored on WhatsApp (buttons always render as reply buttons)." }
title: { type: string, maxLength: 20, description: Button label (max 20 chars) }
url: { type: string, description: URL for url-type buttons (Facebook/Instagram only) }
payload: { type: string, description: "Payload for postback-type buttons. On WhatsApp, this is the reply ID returned on the message.received webhook when the button is tapped." }
phone: { type: string, description: Phone number for phone-type buttons (Facebook only) }
template:
type: object
description: |
Platform-dependent template payload. Ignored on Telegram.
Instagram / Facebook: a generic template (carousel). Set `type: generic`
and provide up to 10 `elements`, each with a `title` (required) and
optional `subtitle`, `imageUrl`, and `buttons`. Mutually exclusive with
the top-level `buttons` field (sending both is a 400); put the card's
buttons on its `elements` instead. On Facebook, `imageAspectRatio`
(`horizontal`, the default, or `square`) sets how Messenger renders the
element images; Instagram has no such setting and rejects it.
WhatsApp: sends an approved WhatsApp template message, the only message
type WhatsApp accepts when the 24-hour customer-service window is closed.
Provide exactly one element carrying the template reference:
`{ "elements": [{ "name": "order_update", "language": "en_US", "components": [...] }] }`
(`type` is ignored on WhatsApp). `components` is optional and is forwarded
unchanged as the `template.components` array of Meta's Cloud API send
payload; use it to fill body/header variables and button parameters, e.g.
`[{ "type": "body", "parameters": [{ "type": "text", "text": "John" }] }]`.
Templates with media headers (image, video, document) must include the
header component with its media link here at send time. To send a template
to a phone number with no existing conversation, or to have media headers
filled in automatically from the template definition, use the
create-conversation endpoint (POST /v1/inbox/conversations) instead.
properties:
type: { type: string, enum: [generic], description: Template type. Required for Instagram/Facebook generic templates; ignored on WhatsApp. }
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
buttons, Flow prompts, location requests, voice-call buttons, and
commerce messages (single product, product list, catalog, and
carousel). When set, takes priority over `buttons` and
`quickReplies`. The shape mirrors Meta's Cloud API `interactive`
object for the types in the enum below.
Use `buttons` / `quickReplies` for simple button replies
(WhatsApp's `interactive.type: "button"`): the abstraction caps at
3 buttons and handles the auto-conversion for you. Use this field
only for the types listed in the enum below.
All interactive messages are session messages: they can only be
sent inside the 24-hour customer service window opened by the
user's last inbound message.
Commerce types (`product`, `product_list`, `catalog_message`, and
product carousels) require a Meta catalog connected to the
WhatsApp Business Account in Commerce Manager. Media carousels
(image/video cards) do not need a catalog.
For `product`, `body` is optional (WhatsApp renders the product
card itself) and `header` is not allowed (the product image is
the header). For `product_list`, a `header` with `type: "text"`
is required. For `carousel`, top-level `header`/`footer` are not
supported; media goes on each card instead.
For `voice_call`, the message renders WhatsApp's native call
button; tapping it starts a voice call to your business number.
Requires WhatsApp Business Calling to be enabled on the sending
number. The optional `parameters.payload` string is echoed back on
the `calls` webhook (as `cta_payload`) for attribution.
For `location_request_message`, `action` may be omitted (we default
it to `{ "name": "send_location" }`). WhatsApp renders a localized
"Send location" button; the user's reply arrives as a regular
location message in the conversation.
For `request_contact_info`, `action` may be omitted (we default it
to `{ "name": "request_contact_info" }`). WhatsApp renders a
localized share button that cannot be relabelled, so put the reason
for asking in `body.text`: this is a consent prompt, and a bare
request converts badly. The reply arrives as an inbound `contacts`
message with `metadata.contactsOrigin` set to `contact_request`,
and we fold the shared number back into the contact automatically.
A `contacts` message with origin `other` is a card the user picked
from their address book and is NOT proof of their own number.
For `catalog_message`, `action` may also be omitted (we default it
to `{ "name": "catalog_message" }`).
For `address_message`, `parameters.country` is required (Meta
rejects the whole send without it); everything else in
`parameters` (`values`, `saved_addresses`, `validation_errors`)
is forwarded to Meta as-is. This is Meta's native structured
shipping-address capture, generally available in India as of
2026-08; check Meta's documentation for current country
availability before relying on it elsewhere. The submitted
address arrives as an `nfm_reply` on the `message.received`
webhook, same as a Flow submission, but with
`metadata.nfmReplyName` set to `address_message` so you can
tell the two apart.
Tap events come back via the `message.received` webhook with
`metadata.interactiveType` set to `list_reply` or `nfm_reply`.
Carts submitted from commerce messages arrive as `metadata.order`;
product inquiries arrive as `metadata.referredProduct`.
required: [type]
properties:
type:
type: string
enum: [list, cta_url, flow, location_request_message, request_contact_info, voice_call, product, product_list, catalog_message, carousel, address_message]
description: Which interactive layout to render.
header:
type: object
description: |
Optional header shown above the body. Required with
`type: "text"` for `product_list`; not allowed for `product`
or `carousel`.
properties:
type: { type: string, enum: [text, image, video, document] }
text: { type: string, description: Required when header type is text. }
image: { type: object, properties: { link: { type: string } } }
video: { type: object, properties: { link: { type: string } } }
document: { type: object, properties: { link: { type: string } } }
body:
type: object
description: Required for every type except `product`, where it is optional.
required: [text]
properties:
text: { type: string, description: Main body text. }
footer:
type: object
description: Optional footer shown below the action.
properties:
text: { type: string }
action:
oneOf:
- type: object
description: List action. `type` on the parent must be `list`.
required: [button, sections]
properties:
button:
type: string
description: CTA label that opens the list (max ~20 chars).
sections:
type: array
minItems: 1
maxItems: 10
description: 1-10 sections. Total rows across all sections cannot exceed 10.
items:
type: object
required: [rows]
properties:
title: { type: string, description: Optional section header (max 24 chars). }
rows:
type: array
minItems: 1
maxItems: 10
items:
type: object
required: [id, title]
properties:
id: { type: string, description: Identifier returned in the webhook as metadata.interactiveId (max 200 chars). }
title: { type: string, description: Row label (max 24 chars). }
description: { type: string, description: Optional description below the title (max 72 chars). }
- type: object
description: CTA URL action. `type` on the parent must be `cta_url`.
required: [name, parameters]
properties:
name: { type: string, enum: [cta_url] }
parameters:
type: object
required: [display_text, url]
properties:
display_text: { type: string, description: Button label (max 20 chars). }
url: { type: string, format: uri, description: Target URL opened when the user taps the button. }
- type: object
description: Voice-call action. `type` on the parent must be `voice_call`. Renders WhatsApp's native call button (requires Calling enabled on the sending number).
required: [name]
properties:
name: { type: string, enum: [voice_call] }
parameters:
type: object
description: All optional.
properties:
display_text: { type: string, maxLength: 20, description: Button label. Defaults to "Call Now". }
ttl_minutes: { type: integer, minimum: 1, maximum: 43200, description: How long the button stays tappable. Defaults to 10080 (7 days). }
payload: { type: string, maxLength: 512, description: Arbitrary string echoed back as `cta_payload` on the `calls` webhook (connect/terminate) for attribution. }
- type: object
description: Flow action. `type` on the parent must be `flow`.
required: [name, parameters]
properties:
name: { type: string, enum: [flow] }
parameters:
type: object
required: [flow_token, flow_id, flow_cta, flow_action]
properties:
flow_message_version: { type: string, enum: ['3'], description: Defaults to "3" when omitted. }
flow_token: { type: string, description: Opaque token you choose to correlate Flow responses with your own state (max 200 chars). }
flow_id: { type: string, description: Published Flow ID from Meta Business Manager. }
flow_cta: { type: string, description: Button label that opens the Flow (max 20 chars). }
flow_action: { type: string, enum: [navigate, data_exchange], description: "`navigate` sends the user to `flow_action_payload.screen`; `data_exchange` posts data to your Flow endpoint." }
flow_action_payload:
type: object
description: Required when flow_action is `navigate`.
properties:
screen: { type: string, description: First screen to show. }
data: { type: object, additionalProperties: true, description: Optional pre-filled data passed to the screen. }
mode: { type: string, enum: [draft], description: Set to `draft` to test an unpublished Flow. }
- type: object
description: Location request action. `type` on the parent must be `location_request_message`. May be omitted entirely; it is defaulted.
required: [name]
properties:
name: { type: string, enum: [send_location] }
- type: object
description: 'Contact-info request action. `type` on the parent must be `request_contact_info`. May be omitted entirely; it is defaulted.'
required: [name]
properties:
name: { type: string, enum: [request_contact_info] }
- type: object
description: |
Single-product action. `type` on the parent must be
`product`. Requires a Meta catalog connected to the
WhatsApp Business Account in Commerce Manager.
required: [catalog_id, product_retailer_id]
properties:
catalog_id: { type: string, description: Meta catalog ID connected to the WhatsApp Business Account. }
product_retailer_id: { type: string, description: Retailer ID (SKU) of the product inside the catalog. }
- type: object
description: |
Multi-product action. `type` on the parent must be
`product_list`. Requires a Meta catalog connected to the
WhatsApp Business Account in Commerce Manager.
required: [catalog_id, sections]
properties:
catalog_id: { type: string, description: Meta catalog ID connected to the WhatsApp Business Account. }
sections:
type: array
minItems: 1
maxItems: 10
description: 1-10 sections. Total products across all sections cannot exceed 30.
items:
type: object
required: [product_items]
properties:
title: { type: string, description: Optional section header. }
product_items:
type: array
minItems: 1
items:
type: object
required: [product_retailer_id]
properties:
product_retailer_id: { type: string, description: Retailer ID (SKU) of the product inside the catalog. }
- type: object
description: |
Catalog-message action. `type` on the parent must be
`catalog_message`. May be omitted entirely; it is defaulted
to `{ "name": "catalog_message" }`. Requires a Meta catalog
connected to the WhatsApp Business Account in Commerce
Manager.
required: [name]
properties:
name: { type: string, enum: [catalog_message] }
parameters:
type: object
properties:
thumbnail_product_retailer_id: { type: string, description: Optional product whose image is used as the message thumbnail. Falls back to the first catalog item when omitted. }
- type: object
description: |
Carousel action. `type` on the parent must be `carousel`.
Carries 2-10 cards, either all product cards
(`type: "product"`, all referencing the same `catalog_id`)
or media cards (any other `type`, e.g. `cta_url`, with a
required image/video `header` on each card). `card_index`
(0-9, non-repeating) is auto-filled sequentially when
omitted. Product carousels require a Meta catalog connected
to the WhatsApp Business Account in Commerce Manager; media
carousels do not.
required: [cards]
properties:
cards:
type: array
minItems: 2
maxItems: 10
items:
type: object
additionalProperties: true
properties:
card_index: { type: integer, minimum: 0, maximum: 9, description: Card position. Auto-filled sequentially when omitted. }
type: { type: string, description: "`product` for a product card; media cards use `cta_url` or a quick-reply type." }
header: { type: object, description: "Media cards only, required. Carries the card's image or video." }
body: { type: object, description: Optional card body text. }
action: { type: object, description: "Product cards: `{ catalog_id, product_retailer_id }` (required). Media cards: the card's button action (e.g. `cta_url` with `parameters.display_text` and `parameters.url`)." }
- type: object
description: |
Address-request action. `type` on the parent must be
`address_message`. Renders Meta's native structured
address form in-chat (generally available in India as
of 2026-08; check Meta's documentation for current
country availability).
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
image as its header, "Reply yes to continue." as the body, and
two tappable reply buttons labelled Yes and No. A tap arrives on
the `message.received` webhook as `metadata.interactiveType:
button_reply`; `metadata.interactiveId` is `btn_0` or `btn_1`.
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)" }
'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).
'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' }
'503': { $ref: '#/components/responses/TemporarilyUnavailable' }
'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' }
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
`url` on a WhatsApp `attachments[]` entry points at, in both the
`message.received` webhook and the List messages response.
**This is an authenticated endpoint, not a public link.** Send
`Authorization: Bearer <your API key>` exactly as you would for any other
call. Passing the URL straight to a browser, an LLM vision API, or a
no-code "download file" step without the header returns `401`. This is
the most common integration mistake on this endpoint, and it differs from
Instagram, Facebook and Telegram, whose `attachments[].url` is a direct
CDN link that needs no header.
**Fetch on receipt, not lazily.** WhatsApp media lives in Meta's media
store, not ours, and it is removed after a limited retention window
(currently 7 days, and Meta has been dropping some inbound media sooner).
Once Meta drops it the media is unrecoverable and this endpoint answers
`400` permanently, so retrying will never succeed. Download and store the
bytes when the webhook arrives.
security:
- bearerAuth: []
parameters:
- { name: mediaId, in: path, required: true, schema: { type: string }, description: 'The media id from `attachments[].payload.id`.' }
- { name: accountId, in: query, required: true, schema: { type: string }, description: 'The WhatsApp account that received the media.' }
responses:
'200':
description: The media binary, streamed with its original content type.
content:
application/octet-stream:
schema: { type: string, format: binary }
'400':
description: 'Media is no longer available on WhatsApp servers (expired or deleted by Meta). Permanent, do not retry.'
'401': { $ref: '#/components/responses/Unauthorized' }
'404':
description: 'Account not found, not accessible to the caller, or the media does not belong to it.'
'502':
description: Meta could not be reached or returned an unexpected error.
/v1/inbox/conversations/{conversationId}/messages/{messageId}:
patch:
x-resource-group: "messages"
operationId: editInboxMessage
summary: Edit message
description: |
Edit the text and/or reply markup of a previously sent Telegram message.
Only supported for Telegram. Returns 400 for other platforms.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID
- name: messageId
in: path
required: true
schema: { type: string }
description: The Telegram message ID to edit
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: Account ID }
text: { type: string, description: New message text }
replyMarkup:
type: object
description: New inline keyboard markup
properties:
type: { type: string, enum: [inline_keyboard] }
keyboard:
type: array
items:
type: array
items:
type: object
properties:
text: { type: string }
callbackData: { type: string }
url: { type: string }
responses:
'200':
description: Message edited
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
data:
type: object
properties:
messageId: { type: integer }
'400':
description: Not supported or invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
delete:
x-resource-group: "messages"
operationId: deleteInboxMessage
summary: Delete message
description: |
Delete a message from a conversation. Platform support varies:
- Telegram: Full delete (bot's own messages anytime, others if admin)
- X: Full delete (own DM events only)
- Bluesky: Delete for self only (recipient still sees it)
- Reddit: Delete from sender's view only
- Facebook, Instagram, WhatsApp: Not supported (returns 400)
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID
- name: messageId
in: path
required: true
schema: { type: string }
description: The platform message ID to delete
- name: accountId
in: query
required: true
schema: { type: string }
description: Account ID
responses:
'200':
description: Message deleted
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
'400':
description: Platform does not support deletion or invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404':
description: Account or conversation not found
/v1/inbox/conversations/{conversationId}/typing:
post:
x-resource-group: "messages"
operationId: sendTypingIndicator
summary: Send typing indicator
description: |
Show a typing indicator in a conversation. Platform support:
- Facebook Messenger: Shows "Page is typing..." for 20 seconds
- Instagram: Shows "typing..." to the recipient (works for both Instagram Login and Facebook Login accounts). The recipient must be signed in to Instagram to see it.
- Telegram: Shows "Bot is typing..." for 5 seconds
- WhatsApp: Shows "typing..." for up to 25 seconds. Requires a recent inbound message in the conversation (Meta references the inbound message id) and also marks that message as read as a side-effect.
- All others: Returns 200 but no-op (platform doesn't support it)
Typing indicators are best-effort. The endpoint always returns 200 even if the platform call fails; `success` reports whether a typing indicator was actually sent to the platform (`false` on unsupported platforms or when the platform call failed).
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: 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:
- `release`: hand the conversation back to the agent so it resumes answering. You must currently hold control (sending any message takes it implicitly).
- `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.
- `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.
The conversation's `threadControl` follows the result; a `conversation.control_changed` webhook fires when Meta later reports the change.
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.
For WhatsApp, this also sends read receipts (blue ticks) to the contact,
EXCEPT on coexistence accounts (where the WhatsApp Business app on the
customer's phone owns read state and we never override it).
This is the explicit, human-driven counterpart to `GET .../messages`,
which is side-effect-free and does NOT mark anything read. Call this when
a user actually views the conversation.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: Account ID }
responses:
'200':
description: Conversation marked read
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
markedCount:
type: integer
description: Number of messages marked read by this call
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404':
description: Account or conversation not found
/v1/inbox/conversations/{conversationId}/messages/{messageId}/reactions:
post:
x-resource-group: "messages"
operationId: addMessageReaction
summary: Add reaction
description: |
Add an emoji reaction to a message. Platform support:
- Telegram: Supports a subset of Unicode emoji reactions
- WhatsApp: Supports any standard emoji (one reaction per message per sender)
- Instagram and Facebook Messenger: Any standard emoji, subject to Meta's 24h messaging window
- Slack: The emoji must have a Slack name (e.g. `:thumbsup:`); unnamed characters return 400
- All others: Returns 400 (not supported)
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID
- name: messageId
in: path
required: true
schema: { type: string }
description: 'The platform message ID (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:
- Telegram: Send empty reaction array to clear
- WhatsApp: Send empty emoji to remove
- Instagram and Facebook Messenger: Sends Meta's `unreact` action; the emoji does not need to be repeated
- Slack: Removes the reaction we previously sent on that message
- All others: Returns 400 (not supported)
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: The conversation ID
- name: messageId
in: path
required: true
schema: { type: string }
description: 'The platform message ID (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.
The URL can be used as attachmentUrl when sending inbox messages.
Files are stored in temporary storage and auto-delete after 7 days.
Maximum file size is 25MB.
Unlike /v1/media/upload (which uses upload tokens for end-user flows),
this endpoint takes your API key in the Authorization header, for programmatic use.
tags: [Messages]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file:
type: string
format: binary
description: The file to upload (max 25MB)
contentType:
type: string
description: 'Override MIME type (e.g. "image/jpeg"). Auto-detected from file if not provided.'
responses:
'200':
description: File uploaded successfully
content:
application/json:
schema:
type: object
properties:
url: { type: string, description: Publicly accessible URL for the uploaded file }
filename: { type: string, description: Generated unique filename }
contentType: { type: string, description: MIME type of the file }
size: { type: integer, description: File size in bytes }
'400':
description: No file provided or file too large
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/accounts/{accountId}/messenger-menu:
get:
x-resource-group: "accounts"
operationId: getMessengerMenu
summary: Get FB persistent menu
description: Get the persistent menu configuration for a Facebook Messenger account.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Persistent menu configuration
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { type: object } }
'400':
description: Not a Facebook account
'401': { $ref: '#/components/responses/Unauthorized' }
put:
x-resource-group: "accounts"
operationId: setMessengerMenu
summary: Set FB persistent menu
description: Set the persistent menu for a Facebook Messenger account. Max 3 top-level items, max 5 nested items.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [persistent_menu]
properties:
persistent_menu:
type: array
description: Persistent menu configuration array (Meta format)
items: { type: object }
responses:
'200':
description: Menu set successfully
'400':
description: Invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
delete:
x-resource-group: "accounts"
operationId: deleteMessengerMenu
description: Removes the persistent menu from Facebook Messenger conversations for this account.
summary: Delete FB persistent menu
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Menu deleted
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/accounts/{accountId}/instagram-ice-breakers:
get:
x-resource-group: "accounts"
operationId: getInstagramIceBreakers
summary: Get IG ice breakers
description: Get the ice breaker configuration for an Instagram account.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Ice breaker configuration
content:
application/json:
schema:
type: object
properties:
data: { type: array, items: { type: object } }
'400':
description: Not an Instagram account
'401': { $ref: '#/components/responses/Unauthorized' }
put:
x-resource-group: "accounts"
operationId: setInstagramIceBreakers
summary: Set IG ice breakers
description: Set ice breakers for an Instagram account. Max 4 ice breakers, question max 80 chars.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [ice_breakers]
properties:
ice_breakers:
type: array
minItems: 1
maxItems: 4
items:
type: object
required: [question, payload]
properties:
question: { type: string, minLength: 1, maxLength: 80 }
payload: { type: string, minLength: 1 }
responses:
'200':
description: Ice breakers set successfully
'400':
description: Invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
delete:
x-resource-group: "accounts"
operationId: deleteInstagramIceBreakers
description: Removes the ice breaker questions from an Instagram account's Messenger experience.
summary: Delete IG ice breakers
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Ice breakers deleted
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/accounts/{accountId}/telegram-commands:
get:
x-resource-group: "accounts"
operationId: getTelegramCommands
summary: Get TG bot commands
description: Get the bot commands configuration for a Telegram account.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Bot commands list
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
command: { type: string, minLength: 1, maxLength: 32, pattern: '^[a-z0-9_]+$' }
description: { type: string, minLength: 1, maxLength: 256 }
'400':
description: Not a Telegram account
'401': { $ref: '#/components/responses/Unauthorized' }
put:
x-resource-group: "accounts"
operationId: setTelegramCommands
summary: Set TG bot commands
description: Set bot commands for a Telegram account.
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [commands]
properties:
commands:
type: array
maxItems: 100
items:
type: object
required: [command, description]
properties:
command: { type: string, minLength: 1, maxLength: 32, pattern: '^[a-z0-9_]+$', description: Bot command without leading slash }
description: { type: string, minLength: 1, maxLength: 256, description: Command description }
responses:
'200':
description: Commands set successfully
'400':
description: Invalid request
'401': { $ref: '#/components/responses/Unauthorized' }
delete:
x-resource-group: "accounts"
operationId: deleteTelegramCommands
description: Clears all bot commands configured for a Telegram bot account.
summary: Delete TG bot commands
tags: [Account Settings]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
responses:
'200':
description: Commands deleted
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/inbox/conversations/{conversationId}/messages/{messageId}/attachments/{index}:
get:
x-resource-group: "messages"
operationId: getMessageAttachment
summary: Resolve message attachment
description: |
Resolve one attachment on a message to a media url that works right now.
Instagram and Facebook sign DM media urls per request and expire them, so
the `url` on a message is a snapshot: it works when you read the message
and stops working later. This endpoint checks the stored url and, when it
has gone stale, re-mints the message's media from Meta and persists it
before answering. The message id never expires, so this URL is the one to
store. It is returned ready-made on each attachment as `refreshUrl` when
you read a message over REST.
**Webhook payloads do not carry `refreshUrl`**, so a webhook-driven
integration builds this URL itself. Every piece is in the event:
`message.conversationId`, `message.platformMessageId`, the attachment's
zero-based position, and `account.accountId`. **`accountId` is a
required query parameter**; omitting it returns `400`
`missing_required_field`, which is the same requirement
`GET /v1/whatsapp/media/{mediaId}` has.
By default it responds `302` to the live media url, so it can be used
directly as an `<img src>` on a browser session. API-key integrators
should pass `?format=json` and read `url` off the body, since a browser
cannot attach an Authorization header to an image request.
Only Instagram and Facebook media can be re-minted. On other platforms
the stored url is returned as-is when it still resolves, and `404`
otherwise.
tags: [Messages]
security: [{ bearerAuth: [] }]
parameters:
- name: conversationId
in: path
required: true
schema: { type: string }
description: 'The conversation ID (Zernio id or platform conversation id)'
- name: messageId
in: path
required: true
schema: { type: string }
description: 'The message id as returned by the list-messages endpoint (the platform message id)'
- name: index
in: path
required: true
schema: { type: integer, minimum: 0 }
description: 'Zero-based position of the attachment in the message''s attachments array'
- name: accountId
in: query
required: true
schema: { type: string }
description: '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.
Responses are cached for up to 10 minutes, so the feed may lag new comments by that
window. Do not poll this endpoint for real-time updates: subscribe to the
`comment.received` webhook, which fires for every new comment across your posts and
carries the post reference needed to keep this list current.
For users with the Ads add-on (accounts on usage-based billing always qualify), the user's Meta ads
(boosted/dark posts) are included too. There's one row per (ad, placement-with-comments):
an ad that runs on both Facebook feed and Instagram feed produces up to two rows (the
Page dark post and the IG media have separate comment threads), each flagged
`isAd: true` with `adId` and `placement` (`id` is `{adId}:{placement}`). Use
`?platform=metaads` to return *only* ad rows; passing `facebook`/`instagram` returns
*organic* posts only (no ads); omitting `platform` returns both. Fetch a row's thread
from GET /v1/ads/{adId}/comments?placement={placement}. Ad comment counts are read with
the Marketing API token (Facebook side) or the connected Instagram account's token
(Instagram side); a row whose count can't be read is omitted.
Pagination walks each account's platform listing. Following `nextCursor` reaches past
the first page on Facebook, Instagram, Threads, LinkedIn and YouTube, since they are
the platforms that support a server-side date window; on the others the listing stops
at its first page. Cursor pagination is only coherent for the default sort
(`sortBy=date`, `sortOrder=desc`): with `sortOrder=asc`, or with `sortBy=comments`,
the cursor filter does not match the sort order and the second page is unreliable.
`nextCursor` is opaque: pass it back verbatim, never construct or parse it, its
composition may change without notice. Because each page re-queries a live window,
results can still shift between requests, so dedupe by `id` on the client.
`commentCount` semantics differ by platform: YouTube's includes replies, Facebook's counts
top-level comments only.
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.
On Facebook and Instagram, passing a COMMENT id as `postId` is also supported and
returns that comment's replies instead of the post's top-level comments. This is not
available on YouTube, where `postId` must be a video id.
Responses are cached for up to 10 minutes, so a page may lag new comments by that
window. Do not poll this endpoint for real-time updates: subscribe to the
`comment.received` webhook, which delivers new comments as they arrive. Your own
writes (creating, replying to, or deleting a comment) refresh the cache immediately.
TikTok is served for accounts connected through the TikTok for Business app: `postId`
is the TikTok video id, each top-level comment carries up to three inline replies, and
`commentId` pages the full reply list of one comment. Developer-app TikTok accounts
return 400 with code `PLATFORM_LIMITATION`.
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
single round-trip. Lets integrators render a preview of the post the user is commenting on
without an additional request. Absent for non-Reddit platforms and when the upstream
response is missing the post listing (deleted post, malformed response).
properties:
id: { type: string, description: Reddit post base36 id (e.g. "1tjtj26") }
fullname: { type: string, description: Fullname with type prefix (e.g. "t3_1tjtj26") }
title: { type: string }
selftext: { type: string, description: Body text for self-posts (empty for link posts) }
author: { type: string, description: "Reddit username, without the u/ prefix" }
subreddit: { type: string, description: "Subreddit name, without the r/ prefix" }
permalink: { type: string, description: Absolute URL to the post on reddit.com }
url: { type: string, description: "For link posts, the external URL; for self-posts, the Reddit permalink" }
score: { type: integer, description: Net upvotes (upvotes minus downvotes) }
numComments: { type: integer }
createdUtc: { type: integer, description: Unix timestamp in seconds }
over18: { type: boolean }
stickied: { type: boolean }
flairText: { type: [string, "null"], description: Link flair text if any }
isGallery: { type: boolean, description: True if the post is a Reddit gallery (multiple images) }
pagination:
type: object
properties:
hasMore: { type: boolean }
cursor: { type: [string, "null"], description: 'Only present when hasMore is true. Absent on the last page, so treat its absence as the end of the thread.' }
meta:
type: object
properties:
platform: { type: string }
postId: { type: string }
accountId: { type: string }
subreddit: { type: [string, "null"], description: (Reddit only) Subreddit name }
lastUpdated: { type: string, format: date-time }
adComments:
type: [object, "null"]
description: "(Facebook/Instagram only) Present when this post has no organic comments but is a boosted post: the engagement lives on the ad. Use the ad-comments endpoint instead."
properties:
adId: { type: string, description: Internal Zernio ad ID }
adCommentsUrl: { type: string, description: "Path to fetch the ad's comments (GET /v1/ads/{adId}/comments)" }
'400':
description: |
Invalid request, or the postId belongs to a Meta ad creative / ad ID rather than an organic post
(code USE_AD_COMMENTS_ENDPOINT; the response includes `adId` and `adCommentsUrl`), or the upstream
platform rejected the request (type platform_error, code platform_api_error; the provider's own
payload is in platformError). Meta returns code 100 with error_subcode 33 both for a story past
its 24h life and for a deleted post, so the two are indistinguishable from the response.
'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.
**Idempotency:** send an `Idempotency-Key` header to make retries safe
(e.g. after a client-side timeout where delivery is unknown): same key +
same body replays the original response (with `Idempotent-Replayed: true`)
instead of posting the comment a second time; same key + different body
returns 422; a key still in flight returns 409. Keys are retained for 24
hours and are scoped to the credential and to this exact path, so reusing
a key against a different postId returns 422 rather than replaying the
other post's response.
Only successful (2xx) responses are stored for replay. If the request
throws or returns a non-2xx status the key is released, so the header
protects the "request succeeded but the response was lost" case. After an
ambiguous failure (a 5xx or a network timeout) list the post's comments
before retrying with the same key, and treat an empty result as
inconclusive rather than as proof nothing was posted.
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).
Requires accountId and commentId query parameters.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
description: Zernio post ID or platform-specific post ID. LinkedIn third-party posts accept full activity URN or numeric ID.
schema: { type: string }
- name: accountId
in: query
required: true
schema: { type: string }
- name: commentId
in: query
required: true
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.
Reddit keeps the same comment id after an edit. Reddit exposes no API to edit a post
title, and a link post has no editable body. To edit a published post's body, use
`POST /v1/posts/{postId}/edit`.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
schema: { type: string }
- name: commentId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, platform, content]
properties:
accountId: { type: string, description: The account ID }
platform: { type: string, enum: [reddit], description: Only Reddit supports editing a comment }
content: { type: string, minLength: 1, description: The new comment body }
example:
accountId: "60f7a1b2c3d4e5f6a7b8c9d0"
platform: "reddit"
content: "Edited: corrected the version number."
responses:
'200':
description: Comment edited
content:
application/json:
schema:
type: object
properties:
status: { type: string }
commentId: { type: string }
platform: { type: string }
example:
status: "success"
commentId: "t1_xyz789"
platform: "reddit"
'400':
description: "Platform does not support editing comments (code: platform_not_supported), or content missing."
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404': { description: Account not found }
'502': { description: Reddit was unreachable or returned an unclassified error. Reddit 4xx statuses are forwarded as-is. }
/v1/inbox/comments/{postId}/{commentId}/moderation:
post:
x-resource-group: "engagement"
operationId: setCommentModeration
summary: Set comment moderation status
description: |
Set a comment's moderation status. Supported on YouTube only.
Use this to work a moderation queue: approve a held comment (`published`), reject it
(`rejected`), or send it back for review (`heldForReview`).
The request must be authorized by the owner of the channel or video the comment
belongs to. You cannot moderate comments on videos you do not own.
This is distinct from `POST /v1/inbox/comments/{postId}/{commentId}/hide`, which
covers Facebook, Instagram, Threads, and X and does not apply to YouTube.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
schema: { type: string }
- name: commentId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, platform, moderationStatus]
properties:
accountId: { type: string, description: The account ID }
platform: { type: string, enum: [youtube], description: Only YouTube supports comment moderation }
moderationStatus:
type: string
enum: [published, rejected, heldForReview]
description: "published approves the comment, rejected removes it, heldForReview returns it to the queue."
banAuthor:
type: boolean
description: >
Also ban the comment's author, auto-rejecting their future comments.
Only valid when moderationStatus is "rejected"; any other pairing is a 400.
example:
accountId: "60f7a1b2c3d4e5f6a7b8c9d0"
platform: "youtube"
moderationStatus: "rejected"
banAuthor: true
responses:
'200':
description: Moderation status applied
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
example: { success: true }
'400':
description: "Platform does not support comment moderation (code: platform_not_supported), or banAuthor was set without moderationStatus=rejected."
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required
'404': { description: Account not found }
'502': { description: "YouTube rejected the request (e.g. the account does not own the video)." }
/v1/inbox/comments/{postId}/{commentId}/hide:
post:
x-resource-group: "engagement"
operationId: hideInboxComment
summary: Hide comment
description: |
Hide a comment on a post. Supported by Facebook, Instagram, Threads, X, and TikTok
(accounts connected through the TikTok for Business app).
Hidden comments are only visible to the commenter and page admin.
For X, the reply must belong to a conversation started by the authenticated user.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
schema: { type: string }
- name: commentId
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: The 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
TikTok (accounts connected through the TikTok for Business app).
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
connected through the TikTok for Business app only; every other platform returns 400.
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
Business app only.
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,
Bluesky, Reddit, LinkedIn, and Instagram in limited release (see below). For
Bluesky, the cid (content identifier) is
required in the request body. For LinkedIn, pass the composite comment URN returned
by the comments endpoints as commentId; an optional reactionType picks the reaction
(defaults to LIKE), and accounts connected before the social-feed scopes were
requested get a 403 with code `linkedin_reconnect_required`.
Instagram is in LIMITED RELEASE and not generally available: the call needs
`instagram_manage_engagement`, which Meta has so far granted this app only under
Standard Access, so it works for app admins, developers and testers of our Meta app
and returns a 403 with code `PLATFORM_BETA_RESTRICTED` for every other account.
That restriction lifts when Meta App Review grants Advanced Access; the constraints
below apply once it does.
Instagram covers comments and replies on feed posts, reels and carousels. Only an
account connected through Facebook Login can be granted
`instagram_manage_engagement`: an Instagram Login connection returns a 400 with
code `instagram_likes_require_facebook_login`, and an account whose token predates
the permission returns a 403 with code `reconnect_required`. Content from private
accounts cannot be liked. Instagram also enforces a burst limit of 50 like or
unlike calls per 5 seconds per Instagram account, and exceeding it locks that
account out of the like API for an hour, so pace bulk loops.
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,
Reddit, LinkedIn, and Instagram in limited release. For Bluesky, the likeUri query
parameter is required. Instagram has the same limited release, Facebook Login,
`instagram_manage_engagement` and burst-limit constraints as liking.
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,
X, Facebook, YouTube, Bluesky, and Instagram in limited release (see below).
Threads, TikTok and Pinterest
expose no like endpoint in their APIs and return 400. Reddit returns 400 too,
pointing at `POST /v1/accounts/{accountId}/reddit-vote`, which covers upvote,
downvote and clear on both posts and comments.
The account does not have to be the one that published the post, which is what
makes executive engagement possible: pass an exec's `accountId` and the brand
post's ID. `postId` accepts either a Zernio post ID or the platform's native post
ID. A Zernio post ID resolves to the entry for `accountId`, falling back to the
post's single entry on the same platform (two entries on that platform is a 400,
so pass the native ID).
LinkedIn requires the `w_member_social_feed` / `w_organization_social_feed`
scopes, which are not retroactive: accounts connected before those were requested
get a 403 with code `linkedin_reconnect_required` until the user reconnects the
account. YouTube spends 50 quota units per call.
Instagram is in LIMITED RELEASE and not generally available: the call needs
`instagram_manage_engagement`, which Meta has so far granted this app only under
Standard Access, so it works for app admins, developers and testers of our Meta app
and returns a 403 with code `PLATFORM_BETA_RESTRICTED` for every other account.
That restriction lifts when Meta App Review grants Advanced Access; the constraints
below apply once it does.
Instagram covers feed images, reels and carousels (stories and private-account
media are not likeable). Only an account connected through Facebook Login can be
granted `instagram_manage_engagement`: an Instagram Login
connection returns a 400 with code `instagram_likes_require_facebook_login`, and an
account whose token predates the permission returns a 403 with code
`reconnect_required`. Instagram also enforces a burst limit of 50 like or unlike
calls per 5 seconds per Instagram account, and exceeding it locks that account out
of the like API for an hour, so pace bulk loops.
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,
Facebook, YouTube, Bluesky, and Instagram in limited release. On YouTube this clears
the rating. Instagram has the same limited release, Facebook Login,
`instagram_manage_engagement` and burst-limit constraints as liking. For Bluesky,
`likeUri` (returned when the post was liked) is required. Reddit uses
`POST /v1/accounts/{accountId}/reddit-vote` with `direction: 0`.
tags: [Comments]
security: [{ bearerAuth: [] }]
parameters:
- name: postId
in: path
required: true
schema: { type: string }
description: Zernio post ID or the platform's native post ID
- name: accountId
in: query
required: true
schema: { type: string }
- name: likeUri
in: query
schema: { type: string }
description: (Bluesky only) The like URI returned when liking
responses:
'200':
description: Post unliked
content:
application/json:
schema:
type: object
properties:
status: { type: string }
postId: { type: string, description: The resolved native post ID }
platform: { type: string }
liked: { type: boolean }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Inbox addon required, or the account is missing the platform scope
'404':
description: Account or post not found
/v1/inbox/comments/{postId}/{commentId}/private-reply:
post:
x-resource-group: "messages"
operationId: sendPrivateReplyToComment
summary: Send private reply
description: |
Send a direct message to the author of a comment. Supported on Instagram and Facebook only.
One reply per comment, must be sent within 7 days. Optionally attach interactive elements:
`quickReplies` (chips above the keyboard, max 13) or `buttons` (1-3 inline postback/url
buttons rendered in the same bubble via Meta's button_template). Chips do not render in
the Instagram Message Requests folder. Since late August 2026 Instagram refuses buttons,
cards and attachments to commenters who do not follow the account (Meta code 2, subcode
1545133, returned here as a non-retryable 400 that says so), and the failed call still
consumes the comment's single private reply. To reach non-followers send plain text and
add buttons once they reply. `quickReplies` and `buttons` are mutually exclusive. When
the comment's single private reply is spent (by this call or an earlier one) the 400
carries `details.privateReplyConsumed: true`; never retry it.
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
Instagram and Messenger apps (not on web). Maximum 13 entries. Mutually
exclusive with `buttons`. Note: chips do NOT render in the Instagram
Message Requests folder where DMs from non-followers land. Use `buttons`
instead for cold reach.
maxItems: 13
items:
type: object
required: [title, payload]
properties:
title:
type: string
maxLength: 20
description: Label shown on the chip. Truncated by Meta beyond 20 characters.
payload:
type: string
description: Opaque value returned in the inbound webhook when the user taps the chip.
imageUrl:
type: string
format: uri
description: Optional thumbnail shown next to the chip title.
buttons:
type: array
description: |
Optional 1-3 inline buttons rendered as part of the same message bubble
via Meta's button_template. Visible in the Instagram Message Requests
folder (unlike quick replies). Mutually exclusive with `quickReplies`.
minItems: 1
maxItems: 3
items:
oneOf:
- type: object
required: [type, title, url]
properties:
type: { type: string, enum: [url] }
title: { type: string, maxLength: 20, description: Label shown on the button. }
url: { type: string, format: uri, description: URL opened when the button is tapped. }
- type: object
required: [type, title, payload]
properties:
type: { type: string, enum: [postback] }
title: { type: string, maxLength: 20, description: Label shown on the button. }
payload: { type: string, description: Opaque value returned in the inbound webhook when the user taps the button. }
- type: object
required: [type, title, phone]
properties:
type: { type: string, enum: [phone] }
title: { type: string, maxLength: 20, description: Label shown on the button. Facebook only. }
phone: { type: string, description: 'E.164 phone number dialed when tapped. Facebook only.' }
examples:
textOnly:
summary: Plain text reply
value:
accountId: "507f1f77bcf86cd799439011"
message: "Hi! Thanks for your comment. I wanted to reach out privately to help with your question."
withQuickReplies:
summary: Comment-to-DM with quick replies
value:
accountId: "507f1f77bcf86cd799439011"
message: "Thanks for commenting! Want me to send the link?"
quickReplies:
- title: "Yes, send it"
payload: "SEND_LINK"
- title: "No thanks"
payload: "DECLINE"
withButtons:
summary: Comment-to-DM with inline buttons (recommended for cold reach)
value:
accountId: "507f1f77bcf86cd799439011"
message: "Thanks for commenting! Tap below to grab the link."
buttons:
- type: "url"
title: "Get the link"
url: "https://tribenest.co/landing"
- type: "postback"
title: "Remind me later"
payload: "REMIND_LATER"
responses:
'200':
description: Private reply sent successfully
content:
application/json:
schema:
type: object
properties:
status:
type: string
example: success
messageId:
type: string
description: The ID of the sent message
commentId:
type: string
description: The comment ID that was replied to
platform:
type: string
enum: [instagram, facebook]
example: instagram
'400':
description: Bad request
content:
application/json:
schema:
$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.
Rate limit: 50 requests per 15-min window. Shares the 300/3hr creation limit with tweet creation.
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, tweetId]
properties:
accountId: { type: string, description: The account ID }
tweetId: { type: string, description: The ID of the tweet to retweet }
responses:
'200':
description: Tweet retweeted
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
tweetId: { type: string }
retweeted: { type: boolean }
platform: { type: string, example: twitter }
'400': { description: Bad request or platform limitation }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'X rejected the request (e.g. suspended account, missing OAuth scope)' }
'404': { description: Account not found }
delete:
x-resource-group: "engagement"
operationId: undoRetweet
summary: Undo retweet
description: |
Undo a retweet (un-repost a tweet).
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
- name: tweetId
in: query
required: true
schema: { type: string }
description: The ID of the original tweet to un-retweet
responses:
'200':
description: Retweet undone
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
tweetId: { type: string }
retweeted: { type: boolean, example: false }
platform: { type: string, example: twitter }
'400': { description: Bad request }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'X rejected the request (e.g. suspended account, missing OAuth scope)' }
'404': { description: Account not found }
/v1/twitter/bookmark:
post:
x-resource-group: "engagement"
operationId: bookmarkPost
summary: Bookmark a tweet
description: |
Bookmark a tweet by ID.
Requires the bookmark.write OAuth scope.
Rate limit: 50 requests per 15-min window.
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, tweetId]
properties:
accountId: { type: string, description: The account ID }
tweetId: { type: string, description: The ID of the tweet to bookmark }
responses:
'200':
description: Tweet bookmarked
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
tweetId: { type: string }
bookmarked: { type: boolean }
platform: { type: string, example: twitter }
'400': { description: Bad request or platform limitation }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'X rejected the request (e.g. suspended account, missing OAuth scope)' }
'404': { description: Account not found }
delete:
x-resource-group: "engagement"
operationId: removeBookmark
summary: Remove bookmark
description: |
Remove a bookmark from a tweet.
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
- name: tweetId
in: query
required: true
schema: { type: string }
description: The ID of the tweet to unbookmark
responses:
'200':
description: Bookmark removed
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
tweetId: { type: string }
bookmarked: { type: boolean, example: false }
platform: { type: string, example: twitter }
'400': { description: Bad request }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'X rejected the request (e.g. suspended account, missing OAuth scope)' }
'404': { description: Account not found }
/v1/twitter/follow:
post:
x-resource-group: "engagement"
operationId: followUser
summary: Follow a user
description: |
Follow a user on X.
Requires the follows.write OAuth scope.
For protected accounts, a follow request is sent instead (pending_follow will be true).
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, targetUserId]
properties:
accountId: { type: string, description: The 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.
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.
The query string is passed through to X unchanged and supports X's search operators
(`from:user`, `-is:retweet`, `is:reply`, `lang:en`, `"exact phrase"`, `conversation_id:123`, boolean `OR`, ...).
Standalone operators like `is:` / `has:` / `lang:` must be combined with a keyword or `from:` clause.
To reply to a found tweet, pass its `id` as the twitter platform entry's `platformSpecificData.replyToTweetId` when creating a post.
Rate limit: 300 requests per 15-min window per connected account.
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The account ID
- name: query
in: query
required: true
schema: { type: string, minLength: 1, maxLength: 512 }
description: 'X search query, max 512 characters. Operators are passed through unchanged; X rejects malformed queries with a 400.'
- name: limit
in: query
schema: { type: integer, minimum: 10, maximum: 100, default: 10 }
description: 'Results per page. X requires a minimum of 10; values below 10 are rejected.'
- name: sinceId
in: query
schema: { type: string, pattern: '^[0-9]{1,19}$' }
description: 'Only return tweets with an ID greater than (more recent than) this numeric tweet ID. Non-numeric values are rejected with 400.'
- name: untilId
in: query
schema: { type: string, pattern: '^[0-9]{1,19}$' }
description: 'Only return tweets with an ID less than (older than) this numeric tweet ID. Non-numeric values are rejected with 400.'
- name: startTime
in: query
schema: { type: string, format: date-time }
description: 'Oldest UTC timestamp (ISO 8601, inclusive), within the last 7 days'
- name: endTime
in: query
schema: { type: string, format: date-time }
description: 'Newest UTC timestamp (ISO 8601, exclusive), within the last 7 days'
- name: cursor
in: query
schema: { type: string }
description: Pagination cursor from a previous response
- name: sortOrder
in: query
schema: { type: string, enum: [recency, relevancy], default: recency }
responses:
'200':
description: Matching tweets
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
tweets:
type: array
items:
type: object
properties:
id: { type: string }
text: { type: string }
created: { type: string, format: date-time }
conversationId: { type: string }
inReplyToTweetId: { type: [string, "null"], description: 'Parent tweet ID when the result is itself a reply' }
lang: { type: string }
author:
type: object
properties:
id: { type: string }
username: { type: string }
displayName: { type: string }
avatar: { type: string }
verifiedType: { type: string }
likeCount: { type: integer }
replyCount: { type: integer }
retweetCount: { type: integer }
quoteCount: { type: integer }
platform: { type: string, example: twitter }
pagination:
type: object
properties:
hasMore: { type: boolean }
cursor: { type: [string, "null"] }
meta:
type: object
properties:
resultCount: { type: integer }
newestId: { type: [string, "null"] }
oldestId: { type: [string, "null"] }
platform: { type: string, example: twitter }
'400': { description: 'Bad request (invalid params, or X rejected the query as malformed)' }
'401': { $ref: '#/components/responses/Unauthorized' }
'402': { description: 'X API spend cap reached for this billing period' }
'403': { description: 'X analytics capability not enabled for this account (code X_ANALYTICS_NOT_ENABLED)' }
'404': { description: Account not found }
'429': { description: 'X search rate limit exceeded (300 requests per 15 minutes)' }
/v1/twitter/tweet:
get:
x-resource-group: "engagement"
operationId: getTweet
summary: Look up a tweet
description: |
Resolve a single tweet by ID or URL into its text, author and public metrics.
Use this to render a post you are referencing, e.g. the tweet quoted by a quote-style post.
Unlike `/v1/twitter/search` this is not limited to the last 7 days and works for any tweet
visible to the connected account.
Billed as an X posts read ($0.005). Repeat lookups of the same tweet within the same UTC day
are charged once.
tags: [Twitter Engagement]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: query
required: true
schema: { type: string }
description: The account ID whose X token is used for the lookup
- name: id
in: query
required: true
schema: { type: string }
description: 'Numeric tweet ID or a tweet URL (e.g. https://x.com/user/status/123...)'
responses:
'200':
description: The resolved tweet
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
tweet:
type: object
properties:
id: { type: string }
text: { type: string }
created: { type: string, format: date-time }
conversationId: { type: string }
inReplyToTweetId: { type: [string, "null"], description: 'Parent tweet ID when the tweet is itself a reply' }
lang: { type: string }
author:
type: object
properties:
id: { type: string }
username: { type: string }
displayName: { type: string }
avatar: { type: string }
verifiedType: { type: string }
likeCount: { type: integer }
replyCount: { type: integer }
retweetCount: { type: integer }
quoteCount: { type: integer }
platform: { type: string, example: twitter }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'402': { description: 'X API spend cap reached for this billing period' }
'403': { description: 'X analytics capability not enabled for this account (code X_ANALYTICS_NOT_ENABLED), or the tweet author is protected or suspended' }
'404': { description: 'Account not found, or the tweet was deleted or never existed' }
'429': { description: 'X rate limit exceeded' }
/v1/inbox/mentions:
get:
x-resource-group: "engagement"
operationId: listInboxMentions
summary: List mentions
description: |
Returns mentions of your connected organization accounts, delivered via platform webhooks.
Currently supports LinkedIn organization mentions.
Requires Inbox addon.
tags: [Mentions]
security: [{ bearerAuth: [] }]
parameters:
- name: accountId
in: query
schema: { type: string }
description: Filter by account ID
- name: profileId
in: query
schema: { type: string }
description: Filter by profile ID
- name: sortOrder
in: query
schema: { type: string, enum: [asc, desc], default: desc }
description: Sort order by publishedAt
- name: limit
in: query
schema: { type: integer, minimum: 1, maximum: 100, default: 25 }
- name: cursor
in: query
schema: { type: string }
description: Cursor for pagination (ID of the last item from the previous page)
responses:
'200':
description: Paginated list of mentions
content:
application/json:
schema:
type: object
properties:
data:
type: array
items:
type: object
properties:
id:
type: string
description: Mention document ID
platform:
type: string
enum: [linkedin]
accountId:
type: string
accountUsername:
type: string
content:
type: string
description: Text of the post that mentioned you
permalink:
type: [string, "null"]
description: URL to the source post on LinkedIn
authorUrn:
type: [string, "null"]
description: LinkedIn URN of the person who mentioned you
authorName:
type: [string, "null"]
description: Display name of the author, resolved from authorUrn. Null when LinkedIn does not allow resolving the profile.
authorUsername:
type: [string, "null"]
description: LinkedIn vanity name of the author (the slug in their profile URL)
authorPicture:
type: [string, "null"]
description: Profile picture URL of the author. LinkedIn CDN URLs expire after some time, so fetch promptly rather than storing long-term.
organizationalEntity:
type: string
description: URN of the organization that was mentioned
publishedAt:
type: string
format: date-time
createdAt:
type: string
format: date-time
pagination:
type: object
properties:
hasMore:
type: boolean
cursor:
type: [string, "null"]
meta:
type: object
properties:
total:
type: integer
sortOrder:
type: string
enum: [asc, desc]
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Inbox addon required }
/v1/inbox/mentions/reply:
post:
x-resource-group: "engagement"
operationId: replyToMention
summary: Reply to a mention
description: |
Reply to a mention of the connected account. Supported on Instagram only.
Two shapes, selected by whether `commentId` is present:
- **Comment mention** (someone @mentioned the account inside a comment): pass both
`mediaId` and `commentId`. Instagram posts a reply under that comment.
- **Caption mention** (someone @mentioned the account in their media caption, so no
comment exists): pass `mediaId` only. Instagram posts a comment on their media.
Story mentions are not supported by Instagram's API.
`GET /v1/inbox/mentions` currently returns LinkedIn mentions only and does
not surface Instagram mentions. Source `mediaId` and `commentId` from Instagram's
`comments` webhook, which is where mention notifications are delivered for accounts
connected through Instagram Login.
tags: [Mentions]
security: [{ bearerAuth: [] }]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, mediaId, message]
properties:
accountId: { type: string, description: The Instagram 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.
Supported platforms: Facebook, Google Business Profile.
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.
**Idempotency:** send an `Idempotency-Key` header to make retries safe
(e.g. after a client-side timeout where delivery is unknown): same key +
same body replays the original response (with `Idempotent-Replayed: true`)
instead of sending the reply to the platform again; same key + different
body returns 422; a key still in flight returns 409. Keys are retained for
24 hours and are scoped to the credential and to this exact path, so
reusing a key against a different reviewId returns 422 rather than
replaying the other review's response.
Only successful (2xx) responses are stored for replay. If the request
throws or returns a non-2xx status the key is released, so the header
protects the "request succeeded but the response was lost" case. After an
ambiguous failure (a 5xx or a network timeout) fetch the review before
retrying with the same key, and treat a missing reply as inconclusive
rather than as proof nothing was sent.
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
# ──────────────────────────────────────────────────────────────────────────
# WHATSAPP-SPECIFIC ENDPOINTS
# Templates, business profile, phone numbers: ACTIVE (no cross-platform equivalent)
# ──────────────────────────────────────────────────────────────────────────
# ──────────────────────────────────────────────────────────────────────────
# TEMPLATES
# ──────────────────────────────────────────────────────────────────────────
/v1/whatsapp/templates:
get:
x-resource-group: "accounts"
operationId: getWhatsAppTemplates
tags: [WhatsApp]
summary: List templates
description: |
List message templates for the WhatsApp Business Account (WABA) associated with the given account.
Templates are fetched directly from the WhatsApp Cloud API. One entry per **name + language**:
a multi-language template appears once per language, each with its own Meta `id`.
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:
Custom template: Provide components with your own content. Submitted to Meta for review (can take up to 24h).
Library template: Provide library_template_name instead of components to use a pre-built template
from Meta's template library. Library templates are pre-approved (no review wait). You can optionally
customize parameters and buttons via library_template_body_inputs and library_template_button_inputs.
Browse available library templates at: https://business.facebook.com/wa/manage/message-templates/
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- accountId
- name
- category
- language
properties:
accountId:
type: string
description: WhatsApp account ID
name:
type: string
pattern: "^[a-z][a-z0-9_]*$"
description: Template name (lowercase, letters/numbers/underscores, must start with a letter)
category:
type: string
enum: [AUTHENTICATION, MARKETING, UTILITY]
description: Template category
language:
type: string
description: Template language code (e.g., en_US)
parameter_format:
type: string
enum: [POSITIONAL, NAMED, positional, named]
description: 'Variable style: POSITIONAL ({{1}}, the default) or NAMED ({{customer_name}}). Named templates provide examples via body_text_named_params / header_text_named_params. Inferred as NAMED when omitted but a named-params example is present.'
components:
type: array
description: "Template components (header, body, footer, buttons, carousel, limited_time_offer). Required for custom templates, omit when using library_template_name."
minItems: 1
items:
$ref: '#/components/schemas/WhatsAppTemplateComponent'
library_template_name:
type: string
description: |
Name of a pre-built template from Meta's template library (e.g., "appointment_reminder",
"auto_pay_reminder_1", "address_update"). When provided, the template is pre-approved
by Meta with no review wait. Omit components when using this field.
library_template_body_inputs:
type: object
description: |
Optional body customizations for library templates. Available options depend on the
template (e.g., add_contact_number, add_learn_more_link, add_security_recommendation,
add_track_package_link, code_expiration_minutes).
library_template_button_inputs:
type: array
description: |
Optional button customizations for library templates. Each item specifies button type
and configuration (e.g., URL, phone number, quick reply).
items:
type: object
properties:
type:
type: string
enum: [quick_reply, url, phone_number]
url:
type: object
properties:
base_url: { type: string }
phone_number:
type: string
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.
Meta stores one template per **name + language**, so a name identifies a family of variants,
each with its own Meta id. Pass `language` to address one variant. Without it, a name with a
single variant resolves to that variant; a name with several returns `409 ambiguous_template`
with `details.languages`. A bare language (`es`) matches a single regional variant (`es_ES`);
if the family has several regional variants for it, that is also a 409. A full code (`es_ES`)
must match exactly. Variants in `PENDING_DELETION` are not part of the family.
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.
Meta stores one template per **name + language**, so a name identifies a family of variants,
each with its own Meta id. Pass `language` to address one variant. Without it, a name with a
single variant resolves to that variant; a name with several returns `409 ambiguous_template`
with `details.languages`. A bare language (`es`) matches a single regional variant (`es_ES`);
if the family has several regional variants for it, that is also a 409. A full code (`es_ES`)
must match exactly. Variants in `PENDING_DELETION` are not part of the family.
Meta only allows editing templates in `APPROVED`, `REJECTED` or `PAUSED` state; an approved
template can be edited once per 24 hours and up to 10 times per 30 days. A component update
sends the variant back to Meta for review, so the `status` returned here is normally `PENDING`;
a TTL-only update keeps an APPROVED variant approved.
The final outcome arrives on the `whatsapp.template.status_updated` webhook (which carries the
variant's `templateId` and `language`). A variant already in `PENDING` cannot be edited again
until Meta finishes reviewing it.
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.
**Without `language` this deletes every language variant of the name** (Meta's own
contract for deletion by name). Pass `language` to delete one variant only; the response
`scope` says which happened. Meta keeps a deleted approved template in `PENDING_DELETION`
for a while and the name cannot be reused for 30 days.
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
and the one the `whatsapp.template.status_updated` webhook carries.
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.
Meta only allows editing templates in `APPROVED`, `REJECTED` or `PAUSED` state; an approved
template can be edited once per 24 hours and up to 10 times per 30 days. A component update
sends the variant back to Meta for review, so the `status` returned here is normally `PENDING`;
a TTL-only update keeps an APPROVED variant approved.
The final outcome arrives on the `whatsapp.template.status_updated` webhook (which carries the
variant's `templateId` and `language`). A variant already in `PENDING` cannot be edited again
until Meta finishes reviewing it.
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.
The name cannot be reused for 30 days once its last variant is deleted.
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
WhatsApp account: whether calling is enabled, the forward-to
destination URI, recording opt-in state, the phone number record id
(use as `{id}` on the read-write calling sub-resource at
/v1/phone-numbers/{id}/whatsapp/calling) and whether SIP digest
credentials are stored (the encrypted password itself is never
returned). Also carries account-level extras (billing eligibility,
current-period spend) that the number-keyed GET does not.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
responses:
'200':
description: Calling config
content:
application/json:
schema:
type: object
properties:
phoneNumberDocId: { type: string, description: "Phone number record ID (use on /v1/phone-numbers/{id}/whatsapp/calling)" }
phoneNumber: { type: string }
callingEnabled: { type: boolean }
callDeepLink: { type: [string, "null"], description: "Public calling deep link (https://wa.me/call/<number>). Tapping it on a phone starts a WhatsApp voice call to this number. Embed it on websites, emails, or QR codes. Null while calling is disabled; not supported by WhatsApp desktop clients." }
forwardTo: { type: [string, "null"], description: "tel:+E164 / sip:... / wss://... destination" }
recordingEnabled: { type: boolean }
sipAuthUsername: { type: [string, "null"] }
sipAuthPasswordConfigured: { type: boolean, description: "True when a SIP digest password is stored. The plaintext is never returned." }
callIconCountries:
type: [array, "null"]
items: { type: string, minLength: 2, maxLength: 2 }
callerIdMode:
type: string
enum: [business, platform]
description: 'Caller ID the forward-leg callee sees on tel: forwards. business = this WhatsApp number; platform = a Zernio number (customer-brought number without verified caller ID; verify via /v1/phone-numbers/{id}/whatsapp/caller-id-verification).'
callerIdVerified: { type: boolean, description: 'True once the number completed caller-ID verification.' }
maxCallDurationSeconds: { type: [integer, "null"], description: 'Hard cap (seconds) on forwarded calls; null = no cap.' }
forwardCallerId: { type: string, enum: [business, caller] }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp phone number not found for this account }
/v1/whatsapp/phone-numbers/{id}/calling:
post:
x-resource-group: "telephony"
operationId: enableWhatsAppCallingLegacy
deprecated: true
tags: [WhatsApp Calling]
summary: Enable calling on a number
description: |
Deprecated alias of `/v1/phone-numbers/{id}/whatsapp/calling`; same contract. New
integrations should use that path.
Enable WhatsApp Business Calling on a connected number. Configures
Meta calling.status=ENABLED with our Telnyx SIP endpoint, fetches and
stores the Meta-issued SIP password (encrypted), and snapshots the
customer's forward-to destination.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: WhatsAppPhoneNumber Mongo ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, forwardTo]
properties:
accountId: { type: string }
forwardTo: { type: string, description: "tel:+E164 / sip:... / wss://... destination" }
sipAuthUsername: { type: string }
sipAuthPassword: { type: string, description: "Stored encrypted, never returned by any endpoint." }
recordingEnabled: { type: boolean, default: false }
callIconCountries:
type: array
items: { type: string, minLength: 2, maxLength: 2 }
maxCallDurationSeconds: { type: integer, minimum: 30, maximum: 14400, description: 'Hard cap (seconds) on a forwarded call; the carrier hangs up both legs when it fires. Safety valve against dead-air billing when a destination hangs up but the signal is lost.' }
forwardCallerId: { type: string, enum: [business, caller], default: business, description: 'Caller ID presented to the forward destination. caller = the WhatsApp user''s number (sip: destinations only; ignored on tel: forwards). Fixes AI-agent trunks that reject seeing the business number call itself.' }
responses:
'200':
description: Calling enabled
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
callingEnabled: { type: boolean }
sipHostname: { type: string }
forwardTo: { type: string }
callerIdMode:
type: string
enum: [business, platform]
description: 'Caller ID the forward-leg callee sees on tel: forwards. business = this WhatsApp number; platform = a Zernio number (customer-brought number without verified caller ID).'
'400': { description: 'Invalid request (including forwardTo set to the number itself)' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp phone number not found }
'422': { description: "Not eligible to enable calling: not on usage-based billing, or the number's messaging limit is below Meta's ~2,000-daily-recipient threshold (TIER_250). Warm the number up to raise the limit." }
patch:
x-resource-group: "telephony"
operationId: updateWhatsAppCallingLegacy
deprecated: true
tags: [WhatsApp Calling]
summary: Update calling config
description: |
Deprecated alias of `/v1/phone-numbers/{id}/whatsapp/calling`; same contract. New
integrations should use that path.
Update fields on an already-enabled number. Only fields present in
the body are written; `undefined` leaves the stored value alone,
explicit `null` clears a nullable field. No Meta side effect, this
only changes local routing state consumed by the Telnyx webhook
handler.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string }
forwardTo: { type: string }
sipAuthUsername: { type: [string, "null"] }
sipAuthPassword: { type: [string, "null"] }
recordingEnabled: { type: boolean }
callIconCountries:
type: [array, "null"]
items: { type: string, minLength: 2, maxLength: 2 }
maxCallDurationSeconds: { type: [integer, "null"], minimum: 30, maximum: 14400, description: 'Hard cap (seconds) on forwarded calls; null clears the cap.' }
forwardCallerId: { type: string, enum: [business, caller], description: 'caller = present the WhatsApp user''s number to the forward destination (sip: only).' }
responses:
'200': { description: Updated }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp phone number not found }
'422': { description: Calling must be enabled before settings can be updated }
delete:
x-resource-group: "telephony"
operationId: disableWhatsAppCallingLegacy
deprecated: true
tags: [WhatsApp Calling]
summary: Disable calling on a number
description: |
Deprecated alias of `/v1/phone-numbers/{id}/whatsapp/calling`; same contract. New
integrations should use that path.
Disable calling. Sends calling.status=DISABLED to Meta (best-effort)
and flips the local `callingEnabled` flag off. forwardTo and SIP
creds are preserved so a re-enable does not lose the destination.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200': { description: Disabled }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp phone number not found }
/v1/whatsapp/call-permissions:
get:
x-resource-group: "contacts"
operationId: getWhatsAppCallPermissions
tags: [WhatsApp Calling]
summary: Check call permission
description: |
Returns the permission state and the list of available actions for
a given consumer wa_id (e.g. `start_call`, `send_call_permission_request`).
Use this before placing a call to decide whether to prompt for
consent first.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: to, in: query, required: true, schema: { type: string }, description: "Consumer wa_id (E.164, leading + optional)" }
responses:
'200':
description: Permission state
content:
application/json:
schema:
type: object
properties:
permission:
type: object
properties:
status: { type: string, enum: [temporary, no_permission, permanent] }
expiration_time: { type: integer, description: Unix seconds when temporary }
actions:
type: array
items:
type: object
properties:
action_name: { type: string, enum: [send_call_permission_request, start_call] }
can_perform_action: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/calls:
post:
x-resource-group: "messages"
operationId: initiateWhatsAppCall
tags: [WhatsApp Calling]
summary: Initiate outbound call
description: |
Initiates an outbound Business-Initiated Call. The Telnyx-side SIP
leg is originated server-side (Option B: SIP-first). Telnyx INVITEs
Meta directly over TLS:5061 with the SIP digest credentials we
captured at calling-enablement time). No client-side SDP is
required; pass only `accountId` and `to`.
To send the consumer the call-consent prompt instead of placing a
call, pass `action: "send_call_permission_request"` (+ optional
`bodyText`). The consumer must tap Allow in WhatsApp before
`start_call` is permitted; Meta limits the prompt to 1 per consumer
per 24h (2 per 7 days) and requires an open 24h service window.
**Idempotency:** send an `Idempotency-Key` header to make retries
safe; same key + same body replays the original response instead of
dialing (and billing) a second call.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/IdempotencyKeyHeader'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, to]
properties:
accountId: { type: string }
to: { type: string, description: "Consumer wa_id (E.164, leading + optional)" }
action:
type: string
enum: [send_call_permission_request]
description: Omit to place a call. Set to send the consent prompt instead.
bodyText:
type: string
maxLength: 1024
description: Body text shown with the consent prompt (send_call_permission_request only).
forwardTo:
type: string
description: |
Per-call destination override. Same accepted shape as the
number's stored forwardTo (tel:+E164, sip:..., wss://...).
recordOverride: { type: boolean }
biz_opaque_callback_data:
type: string
maxLength: 512
description: |
Accepted for forward compatibility. Not currently echoed
back in webhook payloads (SIP-first flow does not pass
through Meta's Graph API where Meta would echo this).
responses:
'200':
description: Call originated; lifecycle continues asynchronously via webhooks.
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
callId: { type: string, description: Internal Call doc ID }
telnyxCallControlId: { type: string, description: Telnyx call_control_id of the outbound leg }
status: { type: string, enum: [dialing] }
direction: { type: string, enum: [outbound] }
to: { type: string }
forwardTo: { type: [string, "null"] }
recordingEnabled: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { description: No active call permission. Send a permission request first. }
'422': { description: "Calling not enabled, BIC country blocked, or missing Meta SIP credentials" }
'502': { description: Telnyx-side originate failed; the Call doc has been marked failed. }
get:
x-resource-group: "messages"
operationId: listWhatsAppCalls
tags: [WhatsApp Calling]
summary: List call history for an account
description: |
Compact history listing for a single connected account. Results are
scoped to the resolved SocialAccount; profile-scoped team members
cannot read calls on sibling accounts.
Cursor pagination: pass the returned `nextCursor` as `before` to fetch
the next page (same scheme as `GET /v1/calls`). `since`/`until` remain
as absolute range filters and combine with the cursor.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: status, in: query, schema: { type: string, enum: [ringing, answered, ended, failed] } }
- { name: direction, in: query, schema: { type: string, enum: [inbound, outbound] } }
- { name: since, in: query, schema: { type: string, format: date-time } }
- { name: until, in: query, schema: { type: string, format: date-time } }
- { name: before, in: query, schema: { type: string, format: date-time }, description: "Return calls with startedAt strictly before this instant (use the previous page's nextCursor)." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200 } }
responses:
'200':
description: Calls
content:
application/json:
schema:
type: object
properties:
calls:
type: array
items:
type: object
properties:
_id: { type: string }
direction: { type: string, enum: [inbound, outbound] }
from: { type: string }
to: { type: string }
status: { type: string, enum: [ringing, answered, ended, failed] }
startedAt: { type: string, format: date-time }
endedAt: { type: string, format: date-time }
durationSeconds: { type: integer }
endReason: { type: string, enum: [hangup, no_answer, rejected, error] }
recordingUrl: { type: string }
billing:
type: object
properties:
metaCostUSD: { type: number, description: 'Meta per-minute charge, billed by Meta directly to your WABA. Display only; not billed by Zernio.' }
telnyxCostUSD: { type: number }
recordingCostUSD: { type: number }
billableCostUSD: { type: number, description: 'Amount Zernio bills you = Telnyx leg + recording (excludes Meta).' }
totalCostUSD: { type: number, description: 'Full cost incl. the Meta portion you pay directly. Display only.' }
currency: { type: string }
nextCursor: { type: [string, "null"], format: date-time, description: Pass as `before` for the next page; null on the last page. }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/calls/{id}:
get:
x-resource-group: "messages"
operationId: getWhatsAppCall
tags: [WhatsApp Calling]
summary: Get a single call
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: Call
content:
application/json:
schema:
type: object
properties:
call:
type: object
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Call not found }
/v1/whatsapp/calls/{id}/recording:
get:
x-resource-group: "messages"
operationId: getWhatsAppCallRecording
tags: [WhatsApp Calling]
summary: Get a call recording
description: |
Resolves a fresh, playable MP3 URL for the call's recording.
Provider-signed recording URLs expire ~10 minutes after signing, so the
`recordingUrl` stored on the call is usually stale by the time it is
played; this endpoint re-signs on demand. Default responds `302 Found`
redirecting to the fresh URL (point an `<audio>` element or a link
straight at this endpoint); pass `as=json` to receive `{ url }` instead.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: as, in: query, schema: { type: string, enum: [json] }, description: "`json` returns `{ url }` instead of a 302 redirect." }
responses:
'302': { description: Redirect to a freshly-signed recording URL. }
'200':
description: Recording URL (`as=json` only).
content:
application/json:
schema:
type: object
properties:
url: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Call not found, or no recording is available for this call" }
'502': { description: Recording provider lookup failed }
/v1/whatsapp/calls/estimate:
get:
x-resource-group: "messages"
operationId: getWhatsAppCallEstimate
tags: [WhatsApp Calling]
summary: Estimate per-minute cost
description: |
Returns a zero-markup estimated cost for an outbound call to the
given destination, broken down by Meta + Telnyx + recording line
items. Costs are pass-through, no margin applied.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: to, in: query, required: true, schema: { type: string } }
- { name: minutes, in: query, schema: { type: integer, minimum: 1, maximum: 120 } }
- { name: recording, in: query, schema: { type: boolean } }
responses:
'200':
description: Estimate
content:
application/json:
schema:
type: object
properties:
destinationCountry: { type: [string, "null"] }
perMinuteUsd: { type: number }
breakdown:
type: object
properties:
metaMinutes: { type: integer }
metaCostUSD: { type: number, description: 'Estimated Meta per-minute charge, billed by Meta directly to your WABA. Display only; not billed by Zernio.' }
telnyxCostUSD: { type: number }
recordingCostUSD: { type: number }
billableCostUSD: { type: number, description: 'Estimated amount Zernio bills you = Telnyx leg + recording (excludes Meta).' }
totalCostUSD: { type: number, description: 'Estimated full cost incl. the Meta portion you pay directly. Display only.' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/calls:
get:
x-resource-group: "messages"
operationId: listCalls
tags: [Calls]
summary: List all calls (unified history)
description: |
Unified call history across ALL of your numbers: both channels
(WhatsApp Business Calling + regular phone/PSTN), inbound and outbound,
newest first. Unlike `GET /v1/voice/calls` (PSTN-only) and
`GET /v1/whatsapp/calls` (one account at a time), this endpoint needs no
`accountId` and never requires fanning out one request per number.
Any row can be opened channel-agnostically via `GET /v1/calls/{id}` and
`GET /v1/calls/{id}/recording`; no branching on `channel` needed. When
the counterparty number matches a CRM contact, `contactId` and
`contactName` are set.
Cursor pagination: pass the returned `nextCursor` as `before` to fetch
the next page. `nextCursor` is null on the last page.
security:
- bearerAuth: []
parameters:
- { name: channel, in: query, schema: { type: string, enum: [whatsapp, pstn] } }
- { name: status, in: query, schema: { type: string, enum: [ringing, answered, ended, failed] } }
- { name: direction, in: query, schema: { type: string, enum: [inbound, outbound] } }
- { name: number, in: query, schema: { type: string }, description: "Exact filter: calls involving this number (typically one of YOUR numbers, to scope history to a single line). E.164, leading + optional." }
- { name: search, in: query, schema: { type: string }, description: "Free-text match on the from/to numbers. Non-digits are stripped, so partial queries like `302` or `+1 302` work." }
- { name: before, in: query, schema: { type: string, format: date-time }, description: "Return calls with startedAt strictly before this instant (use the previous page's nextCursor)." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
responses:
'200':
description: Calls, newest first
content:
application/json:
schema:
type: object
properties:
calls:
type: array
items:
allOf:
- $ref: '#/components/schemas/CallRecord'
- type: object
properties:
contactName: { type: string, description: "CRM contact name for the counterparty, when resolved." }
nextCursor: { type: [string, "null"], format: date-time, description: Pass as `before` for the next page; null on the last page. }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/calls/{id}:
get:
x-resource-group: "messages"
operationId: getCall
tags: [Calls]
summary: Get a call (any channel)
description: |
Channel-agnostic call detail: works for both WhatsApp and regular
phone (PSTN) calls, so any row from `GET /v1/calls` can be opened
without branching on `channel`. Returns the full call including
transcript segments, with `contactId`/`contactName` set when the
counterparty matches a CRM contact.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Call
content:
application/json:
schema:
type: object
properties:
call:
allOf:
- $ref: '#/components/schemas/CallRecord'
- type: object
properties:
contactName: { type: string, description: "CRM contact name for the counterparty, when resolved." }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Call not found }
/v1/calls/{id}/recording:
get:
x-resource-group: "messages"
operationId: getCallRecording
tags: [Calls]
summary: Get a call recording
description: |
Channel-agnostic recording fetch: resolves a fresh, playable MP3 URL
for any call regardless of channel (provider-signed URLs expire ~10
minutes after signing, so this re-signs on demand). Default responds
`302 Found` redirecting to the fresh URL; pass `as=json` to receive
`{ url }` instead.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: as, in: query, schema: { type: string, enum: [json] }, description: "`json` returns `{ url }` instead of a 302 redirect." }
responses:
'302': { description: Redirect to a freshly-signed recording URL. }
'200':
description: Recording URL (`as=json` only).
content:
application/json:
schema:
type: object
properties:
url: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Call not found, or no recording is available for this call" }
'502': { description: Recording provider lookup failed }
/v1/voice/calls:
post:
x-resource-group: "messages"
operationId: createVoiceCall
tags: [Voice]
summary: Place an outbound phone call
description: |
Dials `to` FROM one of your voice-enabled numbers and, on answer,
bridges the callee to the number's stored forward destination, or to
the per-call `forwardTo` override. Destinations can be your own AI
voice agent (Vapi/Retell), a phone, or a SIP endpoint. An optional
`greeting` is spoken to the callee before the bridge.
The 200 response means the call is dialing; the lifecycle continues
asynchronously (track it via `GET /v1/voice/calls/{id}` or the `call.*`
webhooks). Outbound calls are capped per rolling hour (429 when hit).
**Idempotency:** send an `Idempotency-Key` header to make retries safe;
same key + same body replays the original response instead of dialing
(and billing) a second call.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/IdempotencyKeyHeader'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [to]
properties:
to: { type: string, pattern: '^\+\d{6,15}$', description: "Destination to dial, E.164 with leading +." }
fromNumber: { type: string, pattern: '^\+\d{6,15}$', description: Which of your voice-enabled numbers to dial from. Optional when you have exactly one. }
forwardTo: { type: string, description: "Per-call agent override (tel:+E164, sip:..., or wss://...); defaults to the number's stored forward destination." }
greeting: { type: string, maxLength: 1000, description: "Spoken to the callee when they answer, before the bridge." }
recordOverride: { type: boolean, description: Per-call recording toggle; defaults to the number's setting. }
transcribeOverride: { type: boolean, description: Per-call transcription toggle; defaults to the number's setting. }
transcriptionLanguage: { type: string, enum: [auto, en, es], description: "'auto' derives from the callee's country; 'en'/'es' force it." }
amd: { type: boolean, description: Answering-machine detection; defers the bridge until human vs machine is known. }
voicemailDropMessage: { type: string, maxLength: 1000, description: "Spoken to a detected machine, then hang up (implies `amd`). For outbound voicemail drops." }
responses:
'200':
description: Call originated; lifecycle continues asynchronously.
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
callId: { type: string, description: Internal Call doc ID }
telnyxCallControlId: { type: string }
status: { type: string, enum: [dialing] }
direction: { type: string, enum: [outbound] }
from: { type: string }
to: { type: string }
forwardTo: { type: string }
greeting: { type: [string, "null"] }
recordingEnabled: { type: boolean }
transcriptionEnabled: { type: boolean }
transcriptionLanguage: { type: string, enum: [auto, en, es] }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { description: "No voice-enabled number matches `fromNumber`, or no forward destination configured (set the number's forward or pass `forwardTo`)." }
'429': { description: Outbound call limit reached (per rolling hour). }
'502': { description: Carrier-side originate failed; the call has been marked failed. }
get:
x-resource-group: "messages"
operationId: listVoiceCalls
tags: [Voice]
summary: List phone calls
description: |
Your PSTN voice calls (inbound + outbound), newest first. Cursor
pagination: pass the returned `nextCursor` as `before` for the next
page. For a history that also includes WhatsApp calls, use
`GET /v1/calls`.
security:
- bearerAuth: []
parameters:
- { name: status, in: query, schema: { type: string, enum: [ringing, answered, ended, failed] } }
- { name: direction, in: query, schema: { type: string, enum: [inbound, outbound] } }
- { name: number, in: query, schema: { type: string }, description: "Exact filter: calls involving this number (typically one of your DIDs). E.164, leading + optional." }
- { name: before, in: query, schema: { type: string, format: date-time } }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 } }
responses:
'200':
description: Calls, newest first
content:
application/json:
schema:
type: object
properties:
calls:
type: array
items:
$ref: '#/components/schemas/CallRecord'
nextCursor: { type: [string, "null"], format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/voice/calls/{id}:
get:
x-resource-group: "messages"
operationId: getVoiceCall
tags: [Voice]
summary: Get a phone call
description: Full call detail, including the transcript segments when transcription was on.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Call
content:
application/json:
schema:
type: object
properties:
call:
$ref: '#/components/schemas/CallRecord'
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Call not found }
/v1/voice/calls/{id}/end:
post:
x-resource-group: "messages"
operationId: endVoiceCall
tags: [Voice]
summary: Hang up a live call
description: |
Hangs up a live call on demand. Idempotent: ending a call that already
ended (or never connected) returns success with the call's current
status. Final duration/cost are written asynchronously when the hangup
event lands, so the call doc may briefly still show its prior status.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Hangup issued (or the call was already over).
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
callId: { type: string }
status: { type: string, description: "`ending` when a hangup was issued; otherwise the call's current status." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Call not found }
'502': { description: Carrier-side hangup failed }
/v1/voice/calls/{id}/recording:
get:
x-resource-group: "messages"
operationId: getVoiceCallRecording
tags: [Voice]
summary: Get a call recording
description: |
Resolves a fresh, playable MP3 URL for the call's recording
(provider-signed URLs expire ~10 minutes after signing, so this
endpoint re-signs on demand). Default responds `302 Found` redirecting
to the fresh URL; pass `as=json` to receive `{ url }` instead.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: as, in: query, schema: { type: string, enum: [json] }, description: "`json` returns `{ url }` instead of a 302 redirect." }
responses:
'302': { description: Redirect to a freshly-signed recording URL. }
'200':
description: Recording URL (`as=json` only).
content:
application/json:
schema:
type: object
properties:
url: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Call not found, or no recording is available for this call" }
'502': { description: Recording provider lookup failed }
/v1/voice/calls/{id}/transfer:
post:
x-resource-group: "messages"
operationId: transferVoiceCall
tags: [Voice]
summary: Blind-transfer a live call
description: |
Moves the call's current leg to a new destination (a phone number or a
SIP endpoint). This is a BLIND transfer: control of the leg is handed
off and the call ends normally when the transferred leg hangs up. The
caller ID presented on the transfer leg is always your own number.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [to]
properties:
to: { type: string, description: "+E164 phone number (tel: prefix optional) or a sip: URI. wss:// is not a valid transfer target." }
responses:
'200':
description: Transfer issued.
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
callId: { type: string }
transferredTo: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Call not found }
'409': { description: "Call is not connected yet, or has already ended" }
/v1/voice/calls/estimate:
get:
x-resource-group: "messages"
operationId: getVoiceCallEstimate
tags: [Voice]
summary: Estimate call cost
description: |
Pre-call cost estimate for a PSTN call: the carrier leg plus optional
recording and transcription add-ons. Same billing formula as the
post-call invoice, so the quote and the final charge can't disagree.
The per-minute figure is deliberately conservative (the real cost
comes from the settled carrier record after the call), so estimates
trend slightly over the actual invoice. Parity endpoint of
`GET /v1/whatsapp/calls/estimate`, minus the Meta line (PSTN calls
have no separate Meta bill, so `totalCostUSD` equals
`billableCostUSD`).
security:
- bearerAuth: []
parameters:
- { name: to, in: query, required: true, schema: { type: string }, description: "Destination number, E.164 (leading + optional)." }
- { name: minutes, in: query, schema: { type: integer, minimum: 1, maximum: 120, default: 1 } }
- { name: recording, in: query, schema: { type: boolean } }
- { name: transcription, in: query, schema: { type: boolean } }
responses:
'200':
description: Estimate
content:
application/json:
schema:
type: object
properties:
destinationCountry: { type: [string, "null"] }
minutes: { type: integer }
perMinuteUsd: { type: number, description: Billable cost per minute for the requested options. }
breakdown:
type: object
properties:
telnyxCostUSD: { type: number }
recordingCostUSD: { type: number }
transcriptionCostUSD: { type: number }
billableCostUSD: { type: number, description: What Zernio bills for the call. }
totalCostUSD: { type: number, description: Equals billableCostUSD (no separate Meta bill on PSTN); kept for shape parity with the WhatsApp estimate. }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/voice/calls/web:
post:
x-resource-group: "messages"
operationId: createVoiceWebSession
tags: [Voice]
summary: Mint a browser softphone session
description: |
Step 1 of the two-step browser softphone handshake. Mints a WebRTC
session (token + credential) the browser registers with the
`@telnyx/webrtc` SDK. Once registered, call
`POST /v1/voice/calls/web/dial` with the returned `credentialId` to
place the call. The split avoids bridging to a browser that has not
finished registering. The token lives ~1 hour (it must outlive the
whole call, not only the handshake).
security:
- bearerAuth: []
responses:
'200':
description: WebRTC session minted.
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
token: { type: string, description: "Login token for the browser WebRTC SDK." }
credentialId: { type: string, description: "Pass to POST /v1/voice/calls/web/dial once the browser is registered." }
expiresAt: { type: string, format: date-time }
sdk: { type: string, example: '@telnyx/webrtc' }
'401': { $ref: '#/components/responses/Unauthorized' }
'502': { description: Failed to mint the WebRTC session }
/v1/voice/calls/web/dial:
post:
x-resource-group: "messages"
operationId: dialVoiceWebCall
tags: [Voice]
summary: Dial from the browser softphone
description: |
Step 2 of the browser softphone handshake: places an outbound call
whose answered leg is bridged to the browser registered with the
credential from `POST /v1/voice/calls/web`. The call runs through the
normal outbound lane, so it is logged as outbound (from = your number,
to = target) and recorded per the number's settings.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [to, credentialId]
properties:
to: { type: string, pattern: '^\+\d{6,15}$', description: "The number to call, E.164 with leading +." }
credentialId: { type: string, description: "The WebRTC credential id returned by POST /v1/voice/calls/web (the registered browser)." }
fromNumber: { type: string, pattern: '^\+\d{6,15}$', description: Which of your voice-enabled numbers to call from (optional when you have one). }
recordOverride: { type: boolean }
responses:
'200':
description: Call originated; answer/bridge continue asynchronously.
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
callId: { type: string }
telnyxCallControlId: { type: string }
status: { type: string, enum: [dialing] }
direction: { type: string, enum: [outbound] }
from: { type: string }
to: { type: string }
recordingEnabled: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { description: "Invalid or unknown WebRTC credential, or no voice-enabled number matches `fromNumber`" }
'429': { description: Outbound call limit reached (per rolling hour). }
'502': { description: Carrier-side originate failed }
/v1/sms/messages:
post:
x-resource-group: "messages"
operationId: sendSms
tags: [SMS]
summary: Send an SMS/MMS
description: |
Sends an SMS (or MMS when `mediaUrls` is set) from one of your
SMS-enabled numbers. At least one of `text` / `mediaUrls` is required.
Both numbers are normalized to E.164, so `from` matches regardless of
formatting and replies thread into the same inbox conversation.
US numbers must have an approved carrier registration
(`/v1/sms/registrations`) before messages deliver.
**Replies and delivery status arrive as webhooks**, not by polling:
an inbound reply fires `message.received` with `platform: "sms"`, the
first message of a new thread also fires `conversation.started`, and
this message's own outcome fires `message.delivered` or
`message.failed` (the latter carrying the carrier's error code).
**Opted-out recipients:** a send to a number that replied STOP is
refused with `409`, never silently dropped.
**Idempotency:** send an `Idempotency-Key` header to make retries safe:
same key + same body replays the original response instead of sending a
second message; same key + different body returns 422; a key still in
flight returns 409.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/IdempotencyKeyHeader'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [from, to]
properties:
from: { type: string, description: One of your SMS-enabled numbers (E.164; formatting is normalized). }
to: { type: string, description: Recipient number (E.164). }
text: { type: string, description: "Message body. Required unless `mediaUrls` is set. Max 10 SMS segments (1530 GSM-7 or 670 unicode characters)." }
mediaUrls:
type: array
maxItems: 10
items: { type: string, format: uri }
description: Public media URLs to attach (sends as MMS). Max 10.
sendAt:
type: string
format: date-time
description: "Optional. Schedule the send for a future time (ISO 8601 with offset, e.g. `2026-08-01T12:00:00Z`). Must be in the future. The message is queued and the `message.delivered` webhook fires when it actually sends."
responses:
'200':
description: Message accepted for delivery.
content:
application/json:
schema:
type: object
properties:
id: { type: string, description: Message ID }
conversationId: { type: string, description: Inbox conversation the message was threaded into. }
status: { type: string, enum: [sent] }
'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
number, plus `smsReachable` (landlines can't receive SMS). Use it to
validate recipients before sending. Each lookup is billed by the
carrier-data provider, so call it explicitly (e.g. pre-validating an
opt-in list), not on every send.
security:
- bearerAuth: []
parameters:
- { name: number, in: query, required: true, schema: { type: string, minLength: 8 }, description: "Number to look up (E.164; formatting is normalized)." }
responses:
'200':
description: Lookup result. An unknown/invalid number returns lineType `unknown` with `smsReachable` false rather than an error.
content:
application/json:
schema:
type: object
properties:
phoneNumber: { type: string }
carrierName: { type: [string, "null"] }
lineType: { type: string, enum: [mobile, landline, voip, toll-free, unknown] }
smsReachable: { type: boolean, description: True when the line type can receive SMS (not a landline). }
'401': { $ref: '#/components/responses/Unauthorized' }
'502': { description: Lookup provider failed }
/v1/sms/opt-outs:
get:
x-resource-group: "contacts"
operationId: listSmsOptOuts
tags: [SMS]
summary: List SMS opt-outs
description: |
The recipients who opted out of SMS (replied STOP) across your numbers,
most recent first. Compliance surface: you must be able to see and
export your opt-out list. Read-only: a recipient is re-subscribed only
by replying START. Pass `format=csv` to download a CSV instead of JSON.
security:
- bearerAuth: []
parameters:
- { name: format, in: query, schema: { type: string, enum: [json, csv], default: json } }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 5000, default: 500 } }
responses:
'200':
description: Opt-out list
content:
application/json:
schema:
type: object
properties:
optOuts:
type: array
items:
type: object
properties:
phoneNumber: { type: string }
optedOutAt: { type: [string, "null"], format: date-time }
keyword: { type: [string, "null"], description: "The keyword they sent (e.g. STOP), when the carrier recorded one." }
from: { type: [string, "null"], description: Which of your numbers the recipient opted out from. }
count: { type: integer }
text/csv:
schema: { type: string, description: "`format=csv` download. Columns: phone_number, opted_out_at, keyword, our_number" }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/sms/sender-ids:
post:
x-resource-group: "telephony"
operationId: createSmsSenderId
tags: [SMS]
summary: Create an alphanumeric sender ID
description: |
Registers an alphanumeric sender ID (e.g. `ZERNIO`), a branded `from`
for one-way international SMS. No phone number purchase or carrier
registration is needed; once created, pass it as `from` on
`POST /v1/sms/messages`.
Constraints: 3-11 characters (letters, digits, spaces; at least one
letter). Sends cannot reach the US, Canada, or Puerto Rico, are
text-only, and recipients cannot reply. Sender IDs that impersonate
well-known brands or institutions are rejected. Names are not
exclusive: the same sender ID can be registered by any number of
teams. Creating the same sender ID again is a no-op
(re-activates it after a delete).
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [senderId]
properties:
senderId:
type: string
minLength: 3
maxLength: 11
description: 'The sender ID recipients will see (3-11 letters/digits/spaces, at least one letter, no leading/trailing space).'
responses:
'200':
description: Sender ID created (or re-activated).
content:
application/json:
schema:
type: object
properties:
id: { type: string, description: Sender ID resource id. }
senderId: { type: string }
isActive: { type: boolean }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'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.
There is no self-serve raise: the request (desired cap + use case) is
reviewed manually, usually within a business day.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [requestedCap, reason]
properties:
requestedCap: { type: integer, description: 'Desired daily message cap. Must exceed the current cap.' }
reason: { type: string, minLength: 10, maxLength: 2000, description: 'Use case and audience (what you send, to whom, opt-in status).' }
responses:
'200':
description: Request submitted for review.
content:
application/json:
schema:
type: object
properties:
requested: { type: boolean }
requestedCap: { type: integer }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { description: 'A cap-raise request is already awaiting review (code `sender_id_raise_pending`); one at a time.' }
'503': { description: Request could not be submitted; retry or contact support. }
/v1/sms/sender-ids/{id}:
delete:
x-resource-group: "telephony"
operationId: deleteSmsSenderId
tags: [SMS]
summary: Delete an alphanumeric sender ID
description: |
Deactivates the sender ID so it can no longer send. Re-creating the
same sender ID via `POST /v1/sms/sender-ids` re-activates it.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: Sender ID resource id. }
responses:
'200':
description: Sender ID deactivated.
content:
application/json:
schema:
type: object
properties:
deleted: { type: boolean }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Sender ID not found. }
/v1/sms/registrations:
post:
x-resource-group: "telephony"
operationId: startSmsRegistration
tags: [SMS]
summary: Start a carrier registration
description: |
Starts the US carrier registration that a number needs before SMS
delivers: 10DLC (standard company or sole-proprietor) or toll-free
verification. 10DLC needs `brand` + `campaign`; toll-free needs
`tollFree`. Approval is asynchronous; poll
`GET /v1/sms/registrations/{id}` (sole-prop registrations first need
the OTP step: a code is texted to the brand's mobile number, submit it
via `/verify-otp`).
Already have an approved registration? Add another number to it with
`POST /v1/phone-numbers/{id}/sms/reuse-registration` instead
of registering (and paying the carrier brand fee) again.
Rather have your client fill in the legal business details? Create a
share link with `POST /v1/sms/registrations/share`.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [registrationType]
properties:
registrationType: { type: string, enum: [standard_10dlc, sole_prop_10dlc, toll_free] }
phoneNumbers:
type: array
items: { type: string }
description: 'Your numbers this registration covers. When omitted or empty on a 10DLC registration, defaults to your active SMS-enabled US local numbers not already covered by another registration.'
brand:
type: object
description: Required for 10DLC. The legal entity behind the traffic (TCR brand).
required: [entityType, displayName, country, vertical, website, street, city, state, postalCode]
properties:
entityType: { type: string, enum: [PRIVATE_PROFIT, PUBLIC_PROFIT, NON_PROFIT, GOVERNMENT, SOLE_PROPRIETOR] }
displayName: { type: string }
companyName: { type: string, description: Legal company name. Required for every entityType except SOLE_PROPRIETOR. }
ein: { type: string, description: Required for every entityType except SOLE_PROPRIETOR. }
phone: { type: string, description: Business contact phone. Required for every entityType except SOLE_PROPRIETOR. }
mobilePhone: { type: string, description: Required for SOLE_PROPRIETOR; the verification OTP is texted there (US/CA mobile). }
street: { type: string }
city: { type: string }
state: { type: string }
postalCode: { type: string }
country: { type: string, minLength: 2, maxLength: 2, description: "ISO 3166-1 alpha-2 country where the company is registered. Companies worldwide can register standard 10DLC (non-US companies use their local tax ID in `ein`; carrier vetting may take longer). SOLE_PROPRIETOR is US/CA only." }
email: { type: string, format: email, description: Brand contact email; defaults to your account email when omitted. }
website: { type: string, description: "The brand's website (sole proprietors may use a social profile such as LinkedIn or a business Facebook page). Carriers verify the brand against it; a bare domain is normalized to https://." }
vertical: { type: string, enum: [AGRICULTURE, COMMUNICATION, CONSTRUCTION, EDUCATION, ENERGY, ENTERTAINMENT, FINANCIAL, GAMBLING, GOVERNMENT, HEALTHCARE, HOSPITALITY, HUMAN_RESOURCES, INSURANCE, LEGAL, MANUFACTURING, NGO, POLITICAL, POSTAL, PROFESSIONAL, REAL_ESTATE, RETAIL, TECHNOLOGY, TRANSPORTATION] }
stockSymbol: { type: string }
campaign:
type: object
description: |
Required for 10DLC. What you'll send and how recipients opt in/out.
The opt-in/opt-out/help auto-responses (`optinMessage`,
`optoutMessage`, `helpMessage`) are optional: when omitted, a
compliant, brand-named template with the carrier-required
disclosures is generated for you. If you do send them, they must
name the registered brand and carry the disclosures. Submissions
that don't are rewritten to the compliant template before the
campaign is filed.
required: [usecase, description, messageFlow, sample1, sample2, optinKeywords, optoutKeywords, helpKeywords]
properties:
usecase: { type: string }
subUsecases:
type: array
minItems: 2
maxItems: 5
items: { type: string, enum: [2FA, ACCOUNT_NOTIFICATION, CUSTOMER_CARE, DELIVERY_NOTIFICATION, FRAUD_ALERT, HIGHER_EDUCATION, MARKETING, POLLING_VOTING, PUBLIC_SERVICE_ANNOUNCEMENT, SECURITY_ALERT] }
description: |
The concrete kinds of messages a MIXED campaign sends (the carrier
registry requires 2-5, and reviewers match them against the sample
messages). Omitted: a default pair is applied for MIXED.
description: { type: string, minLength: 40, maxLength: 4096 }
messageFlow: { type: string, minLength: 40, maxLength: 2048, description: "How a recipient ends up receiving your messages (the opt-in flow). Include a link to the page or form where they opt in, 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
composes the exact brand/campaign payloads a submission would store
(branding, disclosures, auto-replies), runs deterministic compliance
lints plus an AI reviewer over them, and returns the findings WITHOUT
creating anything. Use it to fix issues before submitting; `block`
severity findings indicate a near-certain carrier rejection.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [registrationType, brand, campaign]
properties:
registrationType: { type: string, enum: [standard_10dlc, sole_prop_10dlc] }
phoneNumbers: { type: array, items: { type: string } }
brand: { type: object, description: 'Same shape as the registration `brand`.' }
campaign: { type: object, description: 'Same shape as the registration `campaign`.' }
messagingBrandName: { type: string, minLength: 2, maxLength: 60 }
responses:
'200':
description: Composed payloads + findings.
content:
application/json:
schema:
type: object
properties:
composed:
type: object
description: 'The exact payloads a submission would store (post-branding, disclosures appended, auto-replies generated).'
properties:
brand: { type: object }
campaign: { type: object }
advisories:
type: array
items:
type: object
properties:
field: { type: [string, "null"], description: 'The payload field the finding is about, when attributable.' }
code: { type: [string, "null"], description: 'Stable rule id for deterministic findings; absent on AI findings.' }
concern: { type: string }
severity: { type: string, enum: [block, warn] }
verdict: { type: string, enum: [pass, warn, fail, unreviewed] }
aiUnavailable: { type: boolean, description: 'True when the AI portion of the check could not run; advisories then contain only deterministic findings.' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/sms/registrations/{id}:
delete:
x-resource-group: "telephony"
operationId: deactivateSmsRegistration
tags: [SMS]
summary: Deactivate a brand/campaign registration
description: |
Terminates the campaign with the carrier registry so the recurring
monthly campaign fee stops (carriers bill the first 3 months of a
campaign regardless). Numbers covered by it can no longer SEND texts
(receiving is unaffected) until they're registered under a new brand.
Irreversible: a deactivated campaign cannot be restored; texting again
later requires a new registration (new one-time and review fees).
Idempotent.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Registration deactivated.
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [deactivated] }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Registration not found }
get:
x-resource-group: "telephony"
operationId: getSmsRegistration
tags: [SMS]
summary: Get a carrier registration
description: Poll this for approval progress after starting a registration.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Registration
content:
application/json:
schema:
type: object
properties:
id: { type: string }
registrationType: { type: string, enum: [standard_10dlc, sole_prop_10dlc, toll_free] }
status: { type: string, enum: [pending, approved, rejected, requested, changes_requested, deactivated], description: "requested/changes_requested = pre-submission review states; customers see them as pending / needs changes." }
brandStatus: { type: string }
campaignStatus: { type: string }
declineReason: { type: [string, "null"] }
phoneNumbers: { type: array, items: { type: string } }
awaitingOtp: { type: boolean }
campaignContent:
type: object
description: |
The submitted campaign content, present only for rejected
registrations with a campaign. Edit and resubmit it via the
appeal endpoint's optional content fields.
properties:
messageFlow: { type: string }
sample1: { type: string }
sample2: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Registration not found }
/v1/sms/registrations/{id}/verify-otp:
post:
x-resource-group: "telephony"
operationId: verifySmsRegistrationOtp
tags: [SMS]
summary: Submit the sole-prop OTP
description: |
Completes sole-proprietor 10DLC brand verification by submitting the
one-time PIN texted to the brand's mobile number. On success the
registration continues to campaign creation automatically.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [otpPin]
properties:
otpPin: { type: string, minLength: 4, maxLength: 10 }
responses:
'200':
description: OTP result
content:
application/json:
schema:
type: object
properties:
verified: { type: boolean }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Registration not found }
/v1/sms/registrations/{id}/resend-otp:
post:
x-resource-group: "telephony"
operationId: resendSmsRegistrationOtp
tags: [SMS]
summary: Re-send the sole-prop OTP
description: |
Re-sends the sole-proprietor verification PIN to the brand's mobile
number. Use it when the original code expired or never arrived. Only
valid while the registration is pending and awaiting its OTP; rate
limited to one send per minute.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: A new code was sent
content:
application/json:
schema:
type: object
properties:
sent: { type: boolean }
'400': { description: 'Malformed `id`, or the registration is not awaiting a verification code.' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Registration not found }
'429': { description: A code was 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
registration that reached campaign creation can be appealed; a
brand-level rejection should be fixed and re-verified instead. On
success the registration returns to `pending`.
Content rejections (e.g. an opt-in flow without a verifiable form link,
or unrealistic samples) should be FIXED in the same call: pass the
corrected `messageFlow` / `sample1` / `sample2` and the campaign is
updated before the appeal is filed, so the reviewer sees the new
content. The current content is on `GET /v1/sms/registrations/{id}`
(`campaignContent`).
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [appealReason]
properties:
appealReason: { type: string, minLength: 10, maxLength: 2000, description: Goes verbatim to the carrier reviewer. Address the decline reason directly. }
messageFlow: { type: string, minLength: 40, maxLength: 2048, description: Corrected opt-in flow; include a link to the opt-in page/form. }
sample1: { type: string, minLength: 20 }
sample2: { type: string, minLength: 20 }
responses:
'200':
description: Appeal submitted; the registration is pending again.
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [pending] }
'400': { description: 'Malformed `id`, or the registration has no campaign to appeal (fix the brand and re-verify instead).' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Registration not found }
/v1/sms/registrations/{id}/respond:
post:
x-resource-group: "telephony"
operationId: respondToSmsRegistrationReview
tags: [SMS]
summary: Reply to a change request
description: |
Replies to a reviewer change request on a registration in
`changes_requested` state: a note, hosted document URLs (from
`POST /v1/sms/opt-in-proof`), or both, sent together. The registration
returns to `requested` (back in review), and you do not need to resubmit the
whole registration. To change the submitted brand/campaign fields
themselves, resubmit via `POST /v1/sms/registrations` with
`resubmitRequestId` instead.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
note: { type: string, maxLength: 2000, description: 'Answer for the reviewer. Required when no files are sent.' }
files:
type: array
maxItems: 5
items: { type: string }
description: 'Hosted document URLs returned by POST /v1/sms/opt-in-proof.'
responses:
'200':
description: Reply recorded; the registration is back in review.
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [requested] }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Registration not found }
'409': { description: Registration is not waiting on changes }
/v1/sms/opt-in-proof:
post:
x-resource-group: "contacts"
operationId: uploadSmsOptInProofFile
tags: [SMS]
summary: Upload opt-in form proof
description: |
Hosts a screenshot (or PDF) of your SMS opt-in form and returns its
public URL. Include that URL in the campaign's `messageFlow` (the
opt-in workflow text). The carrier registry has no attachment field,
so reviewers verify consent by opening links in that answer. Works
before a registration exists (use it when registering) and for
appeals. `/v1/sms/registrations/{id}/opt-in-proof` is an alias.
security:
- bearerAuth: []
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: { type: string, format: binary, description: "PNG, JPG, WebP, GIF or PDF, max 4MB." }
responses:
'200':
description: File hosted.
content:
application/json:
schema:
type: object
properties:
url: { type: string, description: Public URL to reference in the opt-in flow text. }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { description: Unsupported file type or file too large }
/v1/sms/registrations/{id}/opt-in-proof:
post:
x-resource-group: "contacts"
operationId: uploadSmsOptInProof
tags: [SMS]
summary: Upload opt-in form proof for an appeal
description: |
Hosts a screenshot (or PDF) of your SMS opt-in form and returns its
public URL. Carrier reviewers reject campaigns whose consent can't be
verified and ask for a "link/screenshot of the opt-in form". The
registry has no attachment field, so include the returned URL inside
the `messageFlow` you submit with the appeal
(`POST /v1/sms/registrations/{id}/appeal`).
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: { type: string, format: binary, description: "PNG, JPG, WebP, GIF or PDF, max 4MB." }
responses:
'200':
description: File hosted.
content:
application/json:
schema:
type: object
properties:
url: { type: string, description: Public URL to reference in the opt-in flow text. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Registration not found }
'422': { description: Unsupported file type or file too large }
/v1/sms/registrations/share:
post:
x-resource-group: "telephony"
operationId: shareSmsRegistration
tags: [SMS]
summary: Create a registration share link
description: |
Creates a single-use, expiring link (valid 7 days) that lets someone
else (whoever has the legal business details) fill in the carrier
registration form for one of your numbers, without a Zernio login. The
registration is created under your account once the form is submitted.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [numberId]
properties:
numberId: { type: string, description: "Your phone number's ID (from GET /v1/phone-numbers)." }
responses:
'200':
description: Share link created.
content:
application/json:
schema:
type: object
properties:
url: { type: string }
expiresAt: { type: string, format: date-time }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
/v1/whatsapp/template-library:
get:
x-resource-group: "accounts"
operationId: getWhatsAppLibraryTemplate
tags: [WhatsApp Templates]
summary: Look up a library template
description: |
Look up a single pre-approved Template Library template by its exact name, to
introspect its structure before importing it. Most importantly it returns the
template's `buttons`: a library template with `URL` / `PHONE_NUMBER` buttons
must be created with a matching `library_template_button_inputs` array (see
Create Template), or Meta rejects it. Use this to discover which inputs to collect.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp 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 }
# ──────────────────────────────────────────────────────────────────────────
# BUSINESS PROFILE
# ──────────────────────────────────────────────────────────────────────────
/v1/whatsapp/business-profile:
get:
x-resource-group: "accounts"
operationId: getWhatsAppBusinessProfile
tags: [WhatsApp]
summary: Get business profile
description: |
Retrieve the WhatsApp Business profile for the account (about, address, description, email, websites, etc.).
security:
- bearerAuth: []
parameters:
- name: accountId
in: query
required: true
description: WhatsApp account ID
schema:
type: string
responses:
'200':
description: Business profile retrieved successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
businessProfile:
type: object
properties:
about: { type: string, description: Short description (max 139 chars) }
address: { type: string }
description: { type: string, description: Full description (max 512 chars) }
email: { type: string }
profilePictureUrl: { type: string, format: uri }
websites:
type: array
items: { type: string }
maxItems: 2
vertical: { type: string, description: Business category }
'400': { description: accountId is required or phone number ID not found }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
post:
x-resource-group: "accounts"
operationId: updateWhatsAppBusinessProfile
tags: [WhatsApp]
summary: Update business profile
description: |
Update the WhatsApp Business profile. All fields are optional; only provided fields will be updated.
Constraints: about max 139 chars, description max 512 chars, max 2 websites.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- accountId
properties:
accountId:
type: string
description: WhatsApp account ID
about:
type: string
maxLength: 139
description: Short business description (max 139 characters)
address:
type: string
description: Business address
description:
type: string
maxLength: 512
description: Full business description (max 512 characters)
email:
type: string
format: email
description: Business email
websites:
type: array
maxItems: 2
items: { type: string, format: uri }
description: Business websites (max 2)
vertical:
type: string
description: Business category (e.g., RETAIL, ENTERTAINMENT, etc.)
profilePictureHandle:
type: string
description: Handle from resumable upload for profile picture
example:
accountId: "507f1f77bcf86cd799439011"
about: "We help businesses grow"
description: "Premium business solutions for startups and enterprises"
email: "hello@example.com"
websites: ["https://example.com"]
responses:
'200':
description: Business profile updated successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
example:
success: true
message: "Business profile updated successfully"
'400': { description: "Validation error (field too long, too many websites, etc.)" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/business-profile/photo:
post:
x-resource-group: "accounts"
operationId: uploadWhatsAppProfilePhoto
tags: [WhatsApp]
summary: Upload profile picture
description: |
Upload a new profile picture for the WhatsApp Business Profile.
Uses Meta's resumable upload API under the hood: creates an upload session,
uploads the image bytes, then updates the business profile with the resulting handle.
Provide the image either as a binary upload (`multipart/form-data` with `file`)
or as a download URL (`application/json` with `url`). With a URL we fetch the
image server-side and upload the bytes for you. Meta's profile-photo API is
bytes-only, so there is no direct URL passthrough. JPEG/PNG, max 5MB either way.
security:
- bearerAuth: []
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [accountId, file]
properties:
accountId:
type: string
description: WhatsApp 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.
Display name changes require Meta approval and can take 1-3 business days.
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.
The new name must follow WhatsApp naming guidelines (3-512 characters, must represent your business).
Changes require Meta review and approval, which typically takes 1-3 business days.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, displayName]
properties:
accountId:
type: string
description: WhatsApp account ID
displayName:
type: string
minLength: 3
maxLength: 512
description: New display name (must follow WhatsApp naming guidelines)
example:
accountId: "507f1f77bcf86cd799439011"
displayName: "My Business Name"
responses:
'200':
description: Display name change submitted for review
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
displayName:
type: object
properties:
name: { type: string }
status: { type: string, enum: [PENDING_REVIEW] }
'400': { description: "Invalid display name (too short, too long, or missing)" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/business-profile/username:
get:
x-resource-group: "accounts"
operationId: getWhatsappBusinessUsername
tags: [WhatsApp]
summary: Get business username
description: |
Fetch the current WhatsApp Business username and its approval status.
Username status can be `approved` (active), `reserved` (pending activation), or `none` (no username set).
security:
- bearerAuth: []
parameters:
- name: accountId
in: query
required: true
description: WhatsApp account ID
schema:
type: string
responses:
'200':
description: Business username retrieved successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
username:
type: [string, "null"]
description: The current username, or null if none is set
status:
type: string
enum: [approved, reserved, none]
description: Approval state of the username
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
post:
x-resource-group: "accounts"
operationId: setWhatsappBusinessUsername
tags: [WhatsApp]
summary: Set business username
description: |
Claim or transfer a WhatsApp Business username for the account.
Username rules: 3-35 characters, letters/digits/period/underscore only, must contain at least one letter,
no leading or trailing periods, no consecutive periods, no `www` prefix, no domain TLD suffix (e.g. `.com`).
If the desired username is currently held by another account, pass `transferAction: "force_transfer"` to
request a transfer. On failure the API returns a standard error envelope with one of these codes:
`whatsapp_username_unavailable` (already taken and transfer not requested),
`whatsapp_username_ineligible` (account not eligible to claim a username), or
`whatsapp_username_transfer_required` (username is held elsewhere; retry with `force_transfer`).
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, username]
properties:
accountId:
type: string
description: WhatsApp account ID
username:
type: string
minLength: 3
maxLength: 35
description: >
Desired username. Letters, digits, period, and underscore only.
Must contain at least one letter. No leading, trailing, or consecutive periods.
No www prefix. No domain TLD suffix.
transferAction:
type: string
enum: [none, force_transfer]
default: none
description: Pass `force_transfer` to request a transfer if the username is held by another account
example:
accountId: "507f1f77bcf86cd799439011"
username: "mybusiness"
transferAction: "none"
responses:
'200':
description: Username claimed successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
username: { type: string }
status:
type: string
enum: [approved, reserved, none]
example:
success: true
username: "mybusiness"
status: "approved"
'400': { description: "Validation error or username unavailable (see error code in response)" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
delete:
x-resource-group: "accounts"
operationId: deleteWhatsappBusinessUsername
tags: [WhatsApp]
summary: Delete business username
description: |
Release the currently claimed WhatsApp Business username from the account.
After deletion the username becomes available for other accounts to claim.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId:
type: string
description: WhatsApp account ID
example:
accountId: "507f1f77bcf86cd799439011"
responses:
'200':
description: Username deleted successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
example:
success: true
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/business-profile/username/suggestions:
get:
x-resource-group: "accounts"
operationId: getWhatsappBusinessUsernameSuggestions
tags: [WhatsApp]
summary: Get username suggestions
description: |
Retrieve a list of available WhatsApp Business username suggestions based on the account's
business profile name. Use these to help users discover valid, unclaimed usernames.
security:
- bearerAuth: []
parameters:
- name: accountId
in: query
required: true
description: WhatsApp account ID
schema:
type: string
responses:
'200':
description: Username suggestions retrieved successfully
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
suggestions:
type: array
items: { type: string }
description: List of available username suggestions
example:
success: true
suggestions: ["mybusiness", "mybusiness_official", "mybiz"]
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/number-info:
get:
x-resource-group: "contacts"
operationId: getWhatsAppNumberInfo
tags: [WhatsApp Phone Numbers]
summary: Get number status
description: |
Live snapshot of a connected number straight from Meta: the phone-number node
(display number, display name + approval, quality rating, messaging-limit tier,
throughput, official-business badge, connection status, health_status) and its
owning WhatsApp Business Account (name, business verification, timezone,
health_status). Fetched live because Meta updates quality/tier/name/health over
time; the call also refreshes the cached values shown on the connection card.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
responses:
'200':
description: Number + WABA status
content:
application/json:
schema:
type: object
properties:
phone:
type: object
properties:
display_phone_number: { type: string }
verified_name: { type: string }
name_status: { type: string, description: "APPROVED, AVAILABLE_WITHOUT_REVIEW, PENDING_REVIEW, DECLINED, EXPIRED, NONE" }
quality_rating: { type: string, description: "GREEN, YELLOW, RED, UNKNOWN" }
messaging_limit_tier: { type: string, description: "e.g. TIER_250, TIER_1K, TIER_UNLIMITED" }
throughput: { type: object, properties: { level: { type: string, description: "STANDARD or HIGH" } } }
status: { type: string, description: "e.g. CONNECTED" }
is_official_business_account: { type: boolean }
platform_type: { type: string, description: "e.g. CLOUD_API" }
health_status: { type: object, description: Meta's can_send_message health object (messaging + calling signals) }
waba:
type: [object, "null"]
properties:
name: { type: string }
business_verification_status: { type: string, description: "verified, not_verified, pending, ..." }
timezone_id: { type: string, description: Meta integer timezone-enum id }
health_status: { type: object }
'400': { description: Phone number ID not found on account }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/block-users/status:
get:
x-resource-group: "contacts"
operationId: getWhatsAppBlockStatus
tags: [WhatsApp]
summary: Check if a user is blocked
description: |
Definitive blocked-state lookup for a single contact. Meta exposes no
membership endpoint, so this reads Zernio's blocklist mirror (kept in
sync by the block/unblock endpoints; the first call per account
backfills the mirror from Meta's full list). Constant-time regardless
of blocklist size.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string } }
- { name: user, in: query, required: true, schema: { type: string }, description: Consumer wa_id or E.164 phone (leading + optional) }
responses:
'200':
description: Blocked state
content:
application/json:
schema:
type: object
properties:
blocked: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/block-users:
get:
x-resource-group: "contacts"
operationId: getWhatsAppBlockedUsers
tags: [WhatsApp]
summary: List blocked users
description: |
List the WhatsApp users blocked on this number. Cursor-paginated; pass
`nextCursor` back as `after` to fetch the next page. The blocklist holds
up to 64,000 users.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
- { name: limit, in: query, required: false, schema: { type: integer, minimum: 1, maximum: 100 }, description: Page size. }
- { name: after, in: query, required: false, schema: { type: string }, description: Cursor from a previous response's `nextCursor`. }
responses:
'200':
description: Blocked users
content:
application/json:
schema:
type: object
properties:
blockedUsers:
type: array
items:
type: object
properties:
waId: { type: string, description: "WhatsApp user ID (usually the phone number without `+`)." }
nextCursor: { type: [string, "null"], description: Pass as `after` to fetch the next page. Null when there are no more pages. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
post:
x-resource-group: "contacts"
operationId: blockWhatsAppUsers
tags: [WhatsApp]
summary: Block users
description: |
Block one or more WhatsApp users on this number. Blocked users cannot
message your number or see that you are online, and your sends to them
return an error.
Meta constraints, surfaced per-user in `failed` (the request itself still
succeeds for the rest of the batch):
- Only users who messaged your business within the last 24 hours can be
blocked (failures outside the window report "Re-engagement required").
- Up to 1,000 users per request; the blocklist caps at 64,000.
- Other WhatsApp Business accounts cannot be blocked.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, users]
properties:
accountId: { type: string, description: WhatsApp account ID }
users:
type: array
minItems: 1
maxItems: 1000
items: { type: string }
description: Phone numbers (E.164, e.g. "+16505551234") or WhatsApp user IDs to block.
responses:
'200':
description: Per-user results
content:
application/json:
schema:
type: object
properties:
blocked:
type: array
description: Users successfully blocked.
items:
type: object
properties:
input: { type: string, description: The value you sent. }
waId: { type: string, description: Resolved WhatsApp user ID. }
failed:
type: array
description: Users that could not be blocked, with reasons.
items:
type: object
properties:
input: { type: string }
errors: { type: array, items: { type: string } }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
delete:
x-resource-group: "contacts"
operationId: unblockWhatsAppUsers
tags: [WhatsApp]
summary: Unblock users
description: |
Unblock one or more previously blocked WhatsApp users on this number.
Up to 1,000 users per request; per-user failures are reported in
`failed` without failing the rest of the batch.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, users]
properties:
accountId: { type: string, description: WhatsApp 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
first: template review outcomes (approved, rejected, paused, category
changes) and WABA status changes (restricted, disabled, reinstated,
disconnected). Events are captured from Meta webhooks as they happen;
the feed starts at the account's first recorded event and is not
backfilled. Complements the push events `whatsapp.template.status_updated`
and `account.disconnected` with a pollable history.
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
to the WhatsApp account, if one has been provisioned. Reads only from
the stored `metadata.metaCapiDatasetId`, never hits Meta, never
creates a dataset. Use this to detect whether `POST /v1/whatsapp/conversions`
is configured for an account.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
responses:
'200':
description: Dataset lookup
content:
application/json:
schema:
type: object
properties:
datasetId:
type: [string, "null"]
description: Meta dataset ID linked to the WABA, or null if not provisioned yet
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
post:
x-resource-group: "accounts"
operationId: createWhatsAppDataset
tags: [WhatsApp]
summary: Provision CTWA dataset
description: |
Creates (or fetches, if one already exists) the Meta dataset that
Click-to-WhatsApp ad events are reported against via the Conversions
API, and persists its ID on the account as `metadata.metaCapiDatasetId`.
The call is GET-first idempotent: a WABA can only own one CTWA
dataset, so a second call after a successful provision is a safe no-op
that returns the same ID with `created: false`.
Requires the connected WhatsApp account's token to carry the
`whatsapp_business_manage_events` permission. If the permission is
missing the endpoint returns 422 with a message asking the user to
reconnect the account.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId:
type: string
description: WhatsApp account ID
responses:
'200':
description: Dataset provisioned (or already present)
content:
application/json:
schema:
type: object
properties:
datasetId:
type: string
description: Meta dataset ID linked to the WABA
created:
type: boolean
description: True if Meta created a new dataset on this call; false if one already existed
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
'422': { description: Account is missing `whatsapp_business_manage_events`. Reconnect required }
'502': { description: Upstream Meta failure during provisioning }
# ──────────────────────────────────────────────────────────────────────────
# PHONE NUMBERS
# ──────────────────────────────────────────────────────────────────────────
# ─── Phone Numbers (platform-independent) ──────────────────────
#
# A phone number is the unit you buy; WhatsApp, SMS, and Voice are features
# enabled on it. This is the canonical namespace; the
# /v1/whatsapp/phone-numbers/* paths further down are deprecated aliases
# kept for back-compat.
/v1/phone-numbers:
get:
x-resource-group: "telephony"
operationId: listPhoneNumbers
tags: [Phone Numbers]
summary: List phone numbers
description: |
List all phone numbers purchased by the authenticated user.
By default, released numbers are excluded. Connected (bring-your-own)
WhatsApp numbers are returned in the separate `connected` array; they
are not billed and have no provisioning lifecycle.
security:
- bearerAuth: []
parameters:
- name: status
in: query
required: false
description: |
Filter by status (by default excludes released numbers). NOTE:
`status=pending_regulatory` returns the "provisioning" view: numbers
still in review PLUS recently-declined (last 30 days) ones, so a
failed registration surfaces (with `regulatoryDeclineReason`) instead
of silently disappearing. Declined numbers can be re-submitted via
POST /v1/phone-numbers/{id}/remediate. `verifying` is the
short-lived state after the number is provisioned on our side while
WhatsApp confirms the activation code; the number is not billed until
it reaches `active`.
schema:
type: string
enum: [provisioning, verifying, pending_payment, pending_regulatory, regulatory_declined, active, suspended, releasing, released]
- name: profileId
in: query
required: false
description: Filter by profile
schema:
type: string
responses:
'200':
description: Phone numbers retrieved successfully
content:
application/json:
schema:
type: object
properties:
numbers:
type: array
items:
type: object
properties:
_id: { type: string }
phoneNumber: { type: string }
country: { type: string }
status: { type: string, enum: [pending_payment, pending_regulatory, regulatory_declined, provisioning, verifying, active, suspended, releasing, released] }
registrantName: { type: [string, "null"], description: "For regulated numbers, who it's registered for (company or person), set from the submitted KYC." }
telnyxOrderId: { type: [string, "null"], description: Present once the number order has been placed (i.e. the requirement group was approved). Absent while still in identity review. }
monthlyCents: { type: integer, description: "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
numbers linked via Embedded Signup. Not provisioned or billed
by Zernio, so they are not in `numbers`; `accountId` is the
social-account id used by the messaging and inbox endpoints.
Included only on the default and `status=active` views.
items:
type: object
properties:
accountId: { type: string }
phoneNumber: { type: [string, "null"] }
displayName: { type: [string, "null"] }
profileId: { type: [string, "null"] }
connectedAt: { type: [string, "null"], format: date-time }
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
against it). Present when the sandbox is configured; null otherwise.
The `accountId` lets you address the sandbox in compose endpoints.
`template` is the only template a sandbox send is allowed to use.
properties:
phoneNumber: { type: string, example: "+12029087457" }
accountId: { type: [string, "null"] }
template:
type: object
properties:
name: { type: string, example: "sandbox_start" }
language: { type: string, example: "en" }
isSandbox: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/phone-numbers/{id}:
get:
x-resource-group: "telephony"
operationId: getPhoneNumber
tags: [Phone Numbers]
summary: Get phone number
description: |
Retrieve the current status of a purchased phone number. Poll this to
track Meta pre-verification (US sync path) and, for regulated (Tier 3/4)
numbers, the async lifecycle: pending_regulatory → active (or
regulatory_declined). When a regulated number has an Onfido ID step,
`onfidoVerificationUrl` appears here once the order is placed. Forward
it to the end user. (Or subscribe to the whatsapp.number.* webhooks
instead of polling.)
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
description: Phone number record ID
schema:
type: string
responses:
'200':
description: Phone number retrieved successfully
content:
application/json:
schema:
type: object
properties:
phoneNumber:
type: object
properties:
id: { type: string }
phoneNumber: { type: string }
status: { type: string, enum: [pending_payment, pending_regulatory, regulatory_declined, provisioning, verifying, active, suspended, releasing, released] }
country: { type: string }
metaPreverifiedId: { type: string }
metaVerificationStatus: { type: string }
onfidoVerificationUrl: { type: [string, "null"], description: "For a regulated number with an Onfido ID step: the link to forward to the end user. Appears once the order is placed; null otherwise." }
endUserFirstName: { type: [string, "null"] }
endUserLastName: { type: [string, "null"] }
regulatoryDeclineReason: { type: [string, "null"], description: Reviewer rejection reason when status is regulatory_declined. }
provisionedAt: { type: string, format: date-time }
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:
1. Disconnect any linked WhatsApp account
2. Decrement the Stripe subscription quantity (or cancel if last number)
3. Release the number from Telnyx
4. Mark the number as released
security:
- bearerAuth: []
parameters:
- name: id
in: path
required: true
description: Phone number record ID
schema:
type: string
responses:
'200':
description: Phone number released successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
phoneNumber:
type: object
properties:
id: { type: string }
phoneNumber: { type: string }
status: { type: string, description: "\"released\"" }
releasedAt: { type: string, format: date-time }
'400': { description: Phone number is already released or being released }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'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
`phoneNumber` to buy one exact number from `GET /v1/phone-numbers/available`. With
usage-based billing active and a payment method on file, the
number provisions inline and bills per month on your usage-based invoice (there is
no checkout redirect). No payment method on file returns `402 PAYMENT_REQUIRED`;
a regulated country returns `202` with `status: "kyc_required"` and a `kycUrl`.
The monthly price is the one `GET /v1/phone-numbers/countries` quotes for that
country and `numberType` at the time of purchase, and it is stamped on the number:
later rate-card changes never move a number you already own.
Requires usage-based billing (the Usage plan). The maximum number of phone numbers
is determined by the user's plan.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- profileId
properties:
profileId:
type: string
description: >
Preferred profile for the number. One number = one
profile, so when the requested profile already holds a
number the API assigns the next free profile instead (or
creates one) and returns the actual assignment in
`profileId` on the response.
country:
type: string
description: >
ISO 3166-1 alpha-2 country for the number (default US).
International numbers require usage-based billing. Tier 3/4
countries return 202 { status: "kyc_required", kycUrl }.
The customer must complete KYC at that URL before the number
is ordered. See GET /v1/phone-numbers/countries.
default: US
numberType:
type: string
enum: [local, mobile, national, toll_free]
description: >
Which of the country's offered number types to order
(see `types[]` on GET /v1/phone-numbers/countries). Omitted
= the country's default type, which is always the
WhatsApp-safe choice. Capabilities, price, and KYC
requirements are per (country, type): toll_free can never
connect WhatsApp (400 when combined with
connectWhatsapp:true), and wantsSms:true requires an
SMS-capable type.
areaCode:
type: string
pattern: '^\d{1,4}$'
description: >
Area code (national destination code, e.g. 11 for Sao
Paulo) the number must be in. Hard constraint: when the
area has no deliverable inventory the purchase fails with
409 code AREA_CODE_UNAVAILABLE instead of assigning a
number from another area, and later replacements stay in
this area too. Omit for any area. Get live options from
GET /v1/phone-numbers/availability (areaOptions).
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.
connectWhatsapp:
type: boolean
default: true
description: >
A phone number is the unit; WhatsApp is one optional feature.
Pass false to buy a STANDALONE number (Calls/SMS only):
provisioning skips the Meta pre-verify/OTP steps and the
number activates immediately. Omitted defaults to the
WhatsApp provisioning path. WhatsApp can be connected to a
standalone number later from the connect flow.
wantsSms:
type: boolean
default: false
description: >
SMS capability is per-number, not per-country. Pass true to
provision from the SMS-capable inventory pool so the number
can actually text (see also GET /v1/phone-numbers/available
with sms=true, and smsAvailable on GET
/v1/phone-numbers/countries).
wantsWhatsapp:
type: boolean
default: false
description: >
Declare WhatsApp intent on a STANDALONE purchase
(connectWhatsapp:false). The number still activates and
bills immediately, but if WhatsApp's buy-time check rejects
the assigned number, it is automatically swapped for a
WhatsApp-eligible one during the purchase instead of being
delivered with WhatsApp unavailable. Ignored on the WhatsApp
provisioning path (connectWhatsapp omitted or true), which
always delivers a WhatsApp-verified number.
purchaseIntentId:
type: string
maxLength: 100
description: >
Optional idempotency key. Send the same value when retrying
a purchase: if a number was already bought under this key,
the API returns { status: "already_purchased", numberId,
phoneNumber, profileId } instead of provisioning a second
number.
Generate a fresh key for each genuinely new purchase.
allowMultiple:
type: boolean
default: false
description: >
Any second purchase within 10 minutes of a previous one is
rejected with 409 code PURCHASE_VELOCITY as duplicate
protection. Pass true to confirm the additional purchase is
intentional (e.g. bulk provisioning).
example:
profileId: "507f1f77bcf86cd799439011"
country: "DE"
responses:
'200':
description: |
Either a checkout URL (first number) or the provisioned phone number (subsequent numbers).
content:
application/json:
schema:
oneOf:
- type: object
description: Checkout session created (first number)
properties:
message: { type: string }
checkoutUrl: { type: string, format: uri }
- type: object
description: Phone number provisioned inline (subsequent numbers)
properties:
message: { type: string }
phoneNumber:
type: object
properties:
id: { type: string }
phoneNumber: { type: string }
status: { type: string }
country: { type: string }
provisionedAt: { type: string, format: date-time }
metaPreverifiedId: { type: string }
metaVerificationStatus: { type: string }
profileId: { type: string, description: 'The profile the number was actually assigned to.' }
- type: object
description: >
A number was already purchased under the supplied
purchaseIntentId; no new number was provisioned.
properties:
status: { type: string, enum: [already_purchased] }
numberId: { type: string }
phoneNumber: { type: string }
profileId: { type: string, description: 'The profile the number was actually assigned to.' }
'400': { description: "Plan limit reached, profileId required, or country not available" }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: A paid plan is required }
'409':
description: >
Either duplicate-purchase protection (code PURCHASE_VELOCITY:
another number was purchased within the last 10 minutes; retry
with allowMultiple: true to confirm), or the requested areaCode
has no deliverable inventory right now (code
AREA_CODE_UNAVAILABLE: pick another area or omit areaCode; PHONE_NUMBER_UNAVAILABLE: search again and pick another number).
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string, enum: [PURCHASE_VELOCITY, AREA_CODE_UNAVAILABLE, PHONE_NUMBER_UNAVAILABLE] }
'202':
description: Country requires end-user KYC before the number can be ordered.
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [kyc_required] }
country: { type: string }
numberType: { type: string, description: The type that will be ordered after KYC approval. }
kycUrl: { type: string }
'402': { description: 'Payment method required (usage-based billing account with no card on file). Response body carries code: PAYMENT_REQUIRED; add a card, then retry.' }
'422': { description: 'International numbers require usage-based billing (legacy Stripe users are US-only). Response body code: USAGE_BILLING_REQUIRED.' }
/v1/phone-numbers/countries:
get:
x-resource-group: "telephony"
operationId: listPhoneNumberCountries
tags: [Phone Numbers]
summary: List offerable number countries
description: |
The phone number countries available to purchase, each with its flat
monthly price (cents), regulatory tier, whether it needs end-user KYC
(Tier 3/4), and per-feature availability (PSTN calls, WhatsApp, SMS,
and WhatsApp Business Calling outbound). Drives the country picker.
Tier-4 countries appear only when enabled.
security:
- bearerAuth: []
responses:
'200':
description: Offerable countries, cheapest first.
content:
application/json:
schema:
type: object
properties:
countries:
type: array
items:
type: object
properties:
code: { type: string, description: ISO 3166-1 alpha-2 }
tier: { type: integer, enum: [1, 2, 3, 4] }
monthlyCents: { type: integer, 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.
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
country (default US). Optional filters narrow the results. The country
must be offerable (see GET /v1/phone-numbers/countries). Voice
capability is always required; pass `sms=true` to only see numbers that
can also text (SMS support is per-number, not per-country). Numbers a
purchase would refuse are left out, and any result's `phoneNumber` can
be bought exactly by passing it to POST /v1/phone-numbers/purchase.
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
(regulated review is async, 1-3 days). Tells you whether we have
deliverable inventory, and what address the customer needs:
- `addressConstraint: geo` → the registered address MUST be in one of
the returned `areas` (the only place we have stock). A different-area
address passes pre-approval but the number can never be assigned.
- `addressConstraint: country` → any in-country address works.
- `addressConstraint: none` → field-only / instant country, no address.
Call this before starting the KYC form for regulated countries.
security:
- bearerAuth: []
parameters:
- { name: country, in: query, required: true, schema: { type: string }, description: ISO-2 country code. }
- { name: numberType, in: query, required: false, schema: { type: string, enum: [local, mobile, national, toll_free] }, description: "Check a specific offered type (stock and address constraints are per type). Omitted = the country's default type." }
- { name: sms, in: query, required: false, schema: { type: boolean }, description: 'Pass true when the buyer wants SMS: availability, areas, and areaOptions then describe the SMS-capable pool (an SMS purchase orders from it), not the wider voice-only pool.' }
responses:
'200':
description: Availability + address constraint.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
numberType: { type: string }
available: { type: boolean, description: Whether deliverable voice inventory exists right now. }
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.
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.
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
integrations should use that path.
List all WhatsApp phone numbers purchased by the authenticated user.
By default, released numbers are excluded. Connected (bring-your-own)
numbers are returned in the separate `connected` array. They are not
billed and have no provisioning lifecycle.
security:
- bearerAuth: []
parameters:
- name: status
in: query
required: false
description: |
Filter by status (by default excludes released numbers). NOTE:
`status=pending_regulatory` returns the "provisioning" view: numbers
still in review PLUS recently-declined (last 30 days) ones, so a
failed registration surfaces (with `regulatoryDeclineReason`) instead
of silently disappearing. Declined numbers can be re-submitted via
POST /v1/whatsapp/phone-numbers/{id}/remediate. `verifying` is the
short-lived state after the number is provisioned on our side while
WhatsApp confirms the activation code; the number is not billed until
it reaches `active`.
schema:
type: string
enum: [provisioning, verifying, pending_payment, pending_regulatory, regulatory_declined, active, suspended, releasing, released]
- name: profileId
in: query
required: false
description: Filter by profile
schema:
type: string
responses:
'200':
description: Phone numbers retrieved successfully
content:
application/json:
schema:
type: object
properties:
numbers:
type: array
items:
type: object
properties:
_id: { type: string }
phoneNumber: { type: string }
country: { type: string }
status: { type: string, enum: [pending_payment, pending_regulatory, regulatory_declined, provisioning, verifying, active, suspended, releasing, released] }
registrantName: { type: [string, "null"], description: "For regulated numbers, who it's registered for (company or person), set from the submitted KYC." }
telnyxOrderId: { type: [string, "null"], description: Present once the number order has been placed (i.e. the requirement group was approved). Absent while still in identity review. }
monthlyCents: { type: integer, description: "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
numbers linked via Embedded Signup. Not provisioned or billed
by Zernio, so they are not in `numbers`; `accountId` is the
social-account id used by the messaging and inbox endpoints.
Included only on the default and `status=active` views.
items:
type: object
properties:
accountId: { type: string }
phoneNumber: { type: [string, "null"] }
displayName: { type: [string, "null"] }
profileId: { type: [string, "null"] }
connectedAt: { type: [string, "null"], format: date-time }
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
against it). Present when the sandbox is configured; null otherwise.
The `accountId` lets you address the sandbox in compose endpoints.
`template` is the only template a sandbox send is allowed to use.
properties:
phoneNumber: { type: string, example: "+12029087457" }
accountId: { type: [string, "null"] }
template:
type: object
properties:
name: { type: string, example: "sandbox_start" }
language: { type: string, example: "en" }
isSandbox: { type: boolean }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/phone-numbers/purchase:
post:
x-resource-group: "telephony"
operationId: purchaseWhatsAppPhoneNumber
deprecated: true
tags: [WhatsApp Phone Numbers]
summary: Purchase phone number
description: |
Deprecated alias of `/v1/phone-numbers/purchase`; same contract. New
integrations should use that path.
Payment-first: the system provisions a number and auto-assigns it, unless you pass
`phoneNumber` to buy one exact number from `GET /v1/phone-numbers/available`. With
usage-based billing active and a payment method on file, the
number provisions inline and bills per month on your usage-based invoice (there is
no checkout redirect). No payment method on file returns `402 PAYMENT_REQUIRED`;
a regulated country returns `202` with `status: "kyc_required"` and a `kycUrl`.
The monthly price is the one `GET /v1/phone-numbers/countries` quotes for that
country and `numberType` at the time of purchase, and it is stamped on the number:
later rate-card changes never move a number you already own.
Requires usage-based billing (the Usage plan). The maximum number of phone numbers
is determined by the user's plan.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required:
- profileId
properties:
profileId:
type: string
description: Profile to associate the number with
country:
type: string
description: >
ISO 3166-1 alpha-2 country for the number (default US).
International numbers require usage-based billing. Tier 3/4
countries return 202 { status: "kyc_required", kycUrl }.
The customer must complete KYC at that URL before the number
is ordered. See GET /v1/whatsapp/phone-numbers/countries.
default: US
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.
purchaseIntentId:
type: string
maxLength: 100
description: >
Optional idempotency key. Send the same value when retrying
a purchase: if a number was already bought under this key,
the API returns { status: "already_purchased", numberId,
phoneNumber } instead of provisioning a second number.
Generate a fresh key for each genuinely new purchase.
allowMultiple:
type: boolean
default: false
description: >
Any second purchase within 10 minutes of a previous one is
rejected with 409 code PURCHASE_VELOCITY as duplicate
protection. Pass true to confirm the additional purchase is
intentional (e.g. bulk provisioning).
example:
profileId: "507f1f77bcf86cd799439011"
country: "DE"
responses:
'200':
description: |
Either a checkout URL (first number) or the provisioned phone number (subsequent numbers).
content:
application/json:
schema:
oneOf:
- type: object
description: Checkout session created (first number)
properties:
message: { type: string }
checkoutUrl: { type: string, format: uri }
- type: object
description: Phone number provisioned inline (subsequent numbers)
properties:
message: { type: string }
phoneNumber:
type: object
properties:
id: { type: string }
phoneNumber: { type: string }
status: { type: string }
country: { type: string }
provisionedAt: { type: string, format: date-time }
metaPreverifiedId: { type: string }
metaVerificationStatus: { type: string }
- type: object
description: >
A number was already purchased under the supplied
purchaseIntentId; no new number was provisioned.
properties:
status: { type: string, enum: [already_purchased] }
numberId: { type: string }
phoneNumber: { type: string }
'400': { description: "Plan limit reached, profileId required, or country not available" }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: A paid plan is required }
'409':
description: >
Either duplicate-purchase protection (code PURCHASE_VELOCITY:
another number was purchased within the last 10 minutes; retry
with allowMultiple: true to confirm), or the requested areaCode
has no deliverable inventory right now (code
AREA_CODE_UNAVAILABLE: pick another area or omit areaCode; PHONE_NUMBER_UNAVAILABLE: search again and pick another number).
content:
application/json:
schema:
type: object
properties:
error: { type: string }
code: { type: string, enum: [PURCHASE_VELOCITY, AREA_CODE_UNAVAILABLE, PHONE_NUMBER_UNAVAILABLE] }
'202':
description: Country requires end-user KYC before the number can be ordered.
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [kyc_required] }
country: { type: string }
numberType: { type: string, description: The type that will be ordered after KYC approval. }
kycUrl: { type: string }
'402': { description: 'Payment method required (usage-based billing account with no card on file). Response body carries code: PAYMENT_REQUIRED; add a card, then retry.' }
'422': { description: 'International numbers require usage-based billing (legacy Stripe users are US-only). Response body code: USAGE_BILLING_REQUIRED.' }
/v1/whatsapp/phone-numbers/countries:
get:
x-resource-group: "telephony"
operationId: listWhatsAppNumberCountries
deprecated: true
tags: [WhatsApp Phone Numbers]
summary: List offerable number countries
description: |
Deprecated alias of `/v1/phone-numbers/countries`; same contract. New
integrations should use that path.
The WhatsApp number countries available to purchase, each with its flat
monthly price (cents), regulatory tier, whether it needs end-user KYC
(Tier 3/4), and whether outbound calling is available (not BIC-blocked).
Drives the country picker. Tier-4 countries appear only when enabled.
security:
- bearerAuth: []
responses:
'200':
description: Offerable countries, cheapest first.
content:
application/json:
schema:
type: object
properties:
countries:
type: array
items:
type: object
properties:
code: { type: string, description: ISO 3166-1 alpha-2 }
tier: { type: integer, enum: [1, 2, 3, 4] }
monthlyCents: { type: integer, 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
integrations should use that path.
Search the provider's inventory for numbers available to purchase in a
country (default US). Optional filters narrow the results. The country
must be offerable (see GET /v1/whatsapp/phone-numbers/countries).
security:
- bearerAuth: []
parameters:
- { name: country, in: query, required: false, schema: { type: string, default: US } }
- { name: type, in: query, required: false, description: "Number type; defaults to the country's WhatsApp-safe type", schema: { type: string } }
- { name: prefix, in: query, required: false, description: Area code, schema: { type: string } }
- { name: locality, in: query, required: false, description: City, schema: { type: string } }
- { name: contains, in: query, required: false, description: Pattern to match within the number, schema: { type: string } }
- { name: limit, in: query, required: false, schema: { type: integer, default: 20, maximum: 100 } }
responses:
'200':
description: Available numbers.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
numberType: { type: string }
numbers:
type: array
items:
type: object
properties:
phoneNumber: { type: string }
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
integrations should use that path.
Pre-purchase check, so you can warn BEFORE a customer invests in KYC
(regulated review is async, 1-3 days). Tells you whether we have
deliverable inventory, and what address the customer needs:
- `addressConstraint: geo` → the registered address MUST be in one of
the returned `areas` (the only place we have stock). A different-area
address passes pre-approval but the number can never be assigned.
- `addressConstraint: country` → any in-country address works.
- `addressConstraint: none` → field-only / instant country, no address.
Call this before starting the KYC form for regulated countries.
security:
- bearerAuth: []
parameters:
- { name: country, in: query, required: true, schema: { type: string }, description: ISO-2 country code. }
- { name: numberType, in: query, required: false, schema: { type: string, enum: [local, mobile, national, toll_free] }, description: "Check a specific offered type (stock and address constraints are per type). Omitted = the country's default type." }
- { name: sms, in: query, required: false, schema: { type: boolean }, description: 'Pass true when the buyer wants SMS: availability, areas, and areaOptions then describe the SMS-capable pool (an SMS purchase orders from it), not the wider voice-only pool.' }
responses:
'200':
description: Availability + address constraint.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
numberType: { type: string }
available: { type: boolean, description: Whether deliverable voice inventory exists right now. }
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.
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.
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
regulatory requirements) before a number can be ordered: text, date,
address, or file (document) per requirement.
security:
- bearerAuth: []
parameters:
- { name: country, in: query, required: true, schema: { type: string } }
- { name: numberType, in: query, required: false, schema: { type: string, enum: [local, mobile, national, toll_free] }, description: "Requirements and reuse eligibility are per (country, type). Omitted = the country's default type. Pass the same value on the POST." }
responses:
'200':
description: The KYC form spec.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
numberType: { type: string }
fields:
type: array
items:
type: object
properties:
requirementId: { type: string }
label: { type: string }
kind: { type: string, enum: [text, date, address, file, action], description: '"action" = an out-of-band verification (e.g. Onfido); not filled here, fulfilled after the order via a link.' }
description: { type: [string, "null"], description: Plain-English explanation of what to provide. }
example: { type: [string, "null"], description: Concrete example value. }
localTo: { type: [string, "null"], description: ISO country the value must be local to }
reusable:
type: [object, "null"]
description: 'Present when this account already has a reusable verification for the country (skip the form). `fromPhoneNumber`/`details` mirror the first option; `options` lists ALL reusable verifications (agencies hold one per end client), approved-first. Pass the chosen option''s `id` as `reuseOptionId` on POST. Each option''s `instant` says whether it activates in minutes (group-approved) or still queues for carrier review (1-3 days).'
properties:
available: { type: boolean }
fromPhoneNumber: { type: string }
details:
type: array
description: Human-readable summary of the verification on file (field labels + values, plus the address as one line). Best-effort. May be empty if the provider lookup fails.
items:
type: object
properties:
label: { type: string }
value: { type: string }
options:
type: array
description: One entry per distinct approved verification, newest first.
items:
type: object
properties:
id: { type: string, description: 'Opaque option id. Pass as `reuseOptionId` on POST. Stable selection key (a phone number is not unique across verifications).' }
fromPhoneNumber: { type: string, description: Display only. The number this verification was submitted for. Not a selection key. }
instant: { type: boolean, description: 'true = group-approved, a new order activates in minutes; false = documents are reused but the order still queues for carrier review (1-3 days).' }
details:
type: array
items:
type: object
properties:
label: { type: string }
value: { type: string }
documentId: { type: string, description: 'Present on document rows: the Telnyx document id. GET /v1/whatsapp/phone-numbers/kyc/document/{documentId} streams it (auth-scoped, inline PDF).' }
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,
address) for a Tier 3/4 country. Documents are streamed straight to the
number provider and are not stored by Zernio. Builds + submits a
regulatory requirement group and claims a pending_regulatory slot; the
number is ordered + activated once the provider approves (asynchronous).
A customer may hold several same-country numbers in review at once; a
double-submit of the SAME attempt is deduped via `submissionId`.
For an ID-card document requirement, carriers commonly require BOTH sides:
combine the front and back into a single file before uploading (the
dashboard does this automatically). A one-sided ID is a common decline
reason; fix it via POST /v1/phone-numbers/{id}/remediate.
Before submitting, call GET /v1/phone-numbers/availability to
check the country has deliverable inventory and, for geographic-match
countries, which area the address must be in. Otherwise the submission
can pass review yet never be assignable a number.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, country]
properties:
profileId: { type: string }
country: { type: string }
submissionId: { type: string, description: 'Idempotency token for this submission attempt. 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. }
issuedAt: { type: string, format: date, description: 'Date printed on the document (YYYY-MM-DD), for slots the regulator windows such as proof of address. The pre-submit review trusts it over its own read of the PDF.' }
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)" }
'409': { description: 'Either reuse was requested but no prior approved verification exists for this country, or the requested areaCode has no deliverable inventory right now (code: area_code_unavailable; pick another area and resubmit).' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/phone-numbers/kyc/document/{documentId}:
get:
x-resource-group: "telephony"
operationId: viewPhoneNumberKycDocument
tags: [Phone Numbers]
summary: View a KYC document on file
description: |
Stream a document backing a reusable verification (the `documentId`
values from GET /v1/phone-numbers/kyc `reusable.options[].details[]`), so
the account holder can see what's on file before reusing it. Returned
inline as `application/pdf` (uploads are normalized to PDF). Auth-scoped:
a document is viewable only when its id is referenced by one of the
caller's own numbers. Otherwise `404`.
parameters:
- name: documentId
in: path
required: true
schema: { type: string }
description: The Telnyx document id (from `reusable.options[].details[].documentId`).
responses:
'200':
description: The document, streamed inline.
content:
application/pdf:
schema: { type: string, format: binary }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: No such document for this account. }
/v1/phone-numbers/kyc/upload-document:
post:
x-resource-group: "telephony"
operationId: uploadPhoneNumberKycDocument
tags: [Phone Numbers]
summary: Upload a KYC document
description: |
Upload ONE document and get back its provider document id, to reference
from POST /v1/phone-numbers/kyc via `documents[].documentId`.
Send the RAW file bytes as the request body (not base64); put the filename
in the `X-Filename` header. Uploading documents one-per-request keeps each
request under the ~4.5MB body limit. The document streams straight to the
number provider and is not stored by Zernio.
security:
- bearerAuth: []
parameters:
- { name: X-Filename, in: header, required: true, schema: { type: string }, description: URL-encoded original filename. }
requestBody:
required: true
content:
application/octet-stream:
schema: { type: string, format: binary }
responses:
'200':
description: Document uploaded.
content:
application/json:
schema:
type: object
properties:
documentId: { type: string, description: "Reference this id in the KYC submit's documents[].documentId." }
'400': { description: "Missing X-Filename, empty body, or file too large (over 20MB)." }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/phone-numbers/kyc/validate-address:
post:
x-resource-group: "telephony"
operationId: validatePhoneNumberKycAddress
tags: [Phone Numbers]
summary: Pre-validate KYC address
description: |
Optional early check for the address step of a Tier 4 (end-user identity)
registration: validates a postal address for deliverability BEFORE the full
KYC submit, so it can be corrected before any documents are uploaded. The
full submit (POST /v1/phone-numbers/kyc) re-validates the address,
so this call is purely a fast feedback path and skipping it is safe. Only
the postal address is sent (no documents, no gov-ID fields). A region
(`administrative_area`) is required by the validator; when it is omitted the
pre-check is skipped and `{ ok: true, skipped: true }` is returned (the
final submit still validates).
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [country, street_address, locality, postal_code]
properties:
country: { type: string, minLength: 2, maxLength: 2, description: ISO 3166-1 alpha-2 country code. }
street_address: { type: string }
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
provider returned usable corrections, `details.addressSuggestions` carries
them per field for a one-click "apply suggestion" card. (Flat error
envelope: `error` is the human message; `code`/`param`/`details` are
top-level siblings.)
content:
application/json:
schema:
type: object
properties:
error: { type: string, description: Human-readable message. }
type: { type: string }
code: { type: string, example: INVALID_FIELD_VALUE }
param: { type: string, example: address }
details:
type: object
properties:
addressSuggestions:
type: array
items:
type: object
properties:
field: { type: string, example: administrative_area }
label: { type: string, example: State / Province }
value: { type: string, example: Dublin }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/phone-numbers/kyc/share:
post:
x-resource-group: "telephony"
operationId: createPhoneNumberKycLink
tags: [Phone Numbers]
summary: Create a hosted KYC link
description: |
Create a single-use, 7-day hosted KYC link that your end customer
completes WITHOUT a Zernio login. Useful when the person who holds the
ID and address is not your team. They fill the regulated verification on
a Zernio-hosted page; the number provisions under YOUR account once they
submit. Only regulated (KYC) countries are valid: a country that does not
require KYC returns 400.
White-label the page with `branding` (your company name, logo, brand
color). Supply `redirect_url` to send the end customer back to your own
site after a successful submit (completion params are appended; see
below). Listen for the `whatsapp.number.kyc_submitted` webhook to react
when the form is completed.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, country]
properties:
profileId: { type: string }
country: { type: string, minLength: 2, maxLength: 2, description: ISO 3166-1 alpha-2 country code (must be a regulated/KYC country). }
areaCode: { type: string, pattern: '^\d{1,4}$', description: 'Area code (NDC) the eventual number must be in. Hard constraint carried by the link; the end customer filling the form makes no area choice. Options come from GET /v1/phone-numbers/availability (areaOptions).' }
branding:
type: object
description: Optional white-label of the hosted page the end customer sees.
properties:
companyName: { type: string, maxLength: 60, description: 'Your company name, shown on the hosted page.' }
logoUrl: { type: string, format: uri, description: Logo shown above the form. }
brandColor: { type: string, pattern: '^#[0-9a-fA-F]{6}$', description: 'Hex color (e.g. #1a73e8) used as a brand accent on the page.' }
redirect_url:
type: string
format: uri
description: |
Where to send the end customer's browser after a successful
submit. On completion Zernio appends `kyc=submitted` and
`country=<ISO-2>` as query params. When omitted, the hosted
page shows a built-in confirmation screen instead.
responses:
'200':
description: Hosted KYC link created.
content:
application/json:
schema:
type: object
properties:
url: { type: string, description: The hosted link to send your end customer. }
token: { type: string }
expiresAt: { type: string, format: date-time }
'400': { description: Country does not require KYC (not a regulated country). }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/phone-numbers/port-in:
post:
x-resource-group: "telephony"
operationId: createPhoneNumberPortIn
tags: [Phone Numbers]
summary: Port numbers in
description: |
Submit a port-in for one or more existing numbers from another carrier.
Creates the carrier order(s), attaches the end-user (current account)
info plus the LOA and invoice documents, and submits to the losing
carrier. The transfer PIN is forwarded to the carrier and never stored.
Ported numbers arrive voice-ready (and SMS-ready where the order
supports messaging).
Run the portability check (POST /v1/phone-numbers/port-in/check) and
upload the two documents (POST /v1/phone-numbers/port-in/documents)
first. Uploaded documents must be attached to an order within 30
minutes or the carrier deletes them, so upload right before this call.
The carrier may split the numbers into several orders (by country,
number type, losing carrier); `orders` carries per-order results, and a
partial failure still returns 201 with the failed orders' `error` set
(they stay as cancellable drafts).
Non-US/CA numbers additionally need the country-specific values from
GET /v1/phone-numbers/port-in/requirements, passed via `requirements`,
and must be submitted one country per request. When required
information is still missing after submission, the order is kept as a
resumable draft whose `error` / `declineReason` names the gaps.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [phoneNumbers, endUser, loaDocumentId, invoiceDocumentId]
properties:
phoneNumbers:
type: array
minItems: 1
maxItems: 50
items: { type: string }
description: E.164 numbers to port in.
endUser:
type: object
description: |
End-user / current-carrier account info that authorizes the port. The
losing carrier matches every field against its records and rejects the
whole port on a mismatch, so enter values exactly as they appear on the
carrier bill.
required: [entityName, authPersonName, accountNumber, streetAddress, locality, postalCode, countryCode]
properties:
entityName: { type: string, description: "Account holder / business name, as on the carrier account." }
authPersonName: { type: string, description: "Full name (first + last) of the person authorizing the port, 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:
pending, confirmed FOC date, exception reason, or ported.
security:
- bearerAuth: []
responses:
'200':
description: Porting orders
content:
application/json:
schema:
type: object
properties:
orders:
type: array
items:
type: object
properties:
id: { type: string }
status: { type: string, enum: [draft, pending, foc_confirmed, ported, exception, cancelled] }
telnyxStatusValue: { type: [string, "null"], description: Raw carrier status string. }
phoneNumbers: { type: array, items: { type: string } }
fastPortEligible: { type: [boolean, "null"] }
focDatetimeRequested: { type: [string, "null"], format: date-time }
focDatetimeActual: { type: [string, "null"], format: date-time }
declineReason: { type: [string, "null"] }
submittedAt: { type: [string, "null"], format: date-time }
portedAt: { type: [string, "null"], format: date-time }
createdAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/phone-numbers/port-in/check:
post:
x-resource-group: "telephony"
operationId: checkPhoneNumberPortability
tags: [Phone Numbers]
summary: Check portability
description: |
Pre-flight portability check: whether each number can be ported in and
whether it qualifies for FastPort, BEFORE the user commits to a port
order (LOA, invoice, service address). Read-only; creates no order and
bills nothing.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [phoneNumbers]
properties:
phoneNumbers:
type: array
minItems: 1
maxItems: 50
items: { type: string }
description: "E.164 numbers to check, e.g. +13035550000."
responses:
'200':
description: Per-number portability.
content:
application/json:
schema:
type: object
properties:
results:
type: array
items:
type: object
properties:
phoneNumber: { type: string }
portable: { type: boolean }
fastPortable: { type: boolean, description: Qualifies for the carrier's accelerated FastPort lane. }
lineType: { type: [string, "null"], description: "Line type when known (mobile, landline, voip…). A US/CA mobile number requires the transfer PIN at submit." }
countryCode: { type: [string, "null"], description: "ISO country of the number. Pass it to GET /v1/phone-numbers/port-in/requirements for international numbers." }
phoneNumberType: { type: [string, "null"], description: "Carrier number-type classification (local, mobile, national, toll_free…), the numberType for the requirements endpoint." }
notPortableReason: { type: [string, "null"], description: Carrier reason when not portable; null when portable. }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/phone-numbers/port-in/documents:
post:
x-resource-group: "telephony"
operationId: uploadPhoneNumberPortInDocument
tags: [Phone Numbers]
summary: Upload a porting document
description: |
Upload ONE porting document and get back its `documentId`. For the
signed LOA / carrier invoice the id goes to `loaDocumentId` /
`invoiceDocumentId`; for a country-specific document requirement
(international ports) it becomes that requirement's `fieldValue`.
Requirement documents are normalized to PDF automatically (regulators
reject raw images). PDF, JPEG, or PNG, 10MB max. Uploads must be
attached to an order within 30 minutes or the carrier deletes them.
security:
- bearerAuth: []
requestBody:
required: true
content:
multipart/form-data:
schema:
type: object
required: [file]
properties:
file: { type: string, format: binary, description: "The document (PDF/JPEG/PNG, 10MB max)." }
kind: { type: string, description: "'loa', 'invoice', or any short slug for requirement documents. Informational; used for the stored filename." }
responses:
'200':
description: Document uploaded.
content:
application/json:
schema:
type: object
properties:
documentId: { type: string }
'400': { description: "Missing file, file too large, or unsupported type" }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/phone-numbers/port-in/requirements:
get:
x-resource-group: "telephony"
operationId: getPhoneNumberPortInRequirements
tags: [Phone Numbers]
summary: Country porting requirements
description: |
The country-specific information a port-in needs BEYOND the LOA,
invoice, and account/address details, such as an ID copy, proof of
address, a tax id, or a porting code. Call it after the portability
check (which returns each number's `countryCode` and
`phoneNumberType`), render the fields, and pass the collected values as
the create request's `requirements`. US/CA return an empty list.
security:
- bearerAuth: []
parameters:
- { name: country, in: query, required: true, schema: { type: string, minLength: 2, maxLength: 2 }, description: "ISO country of the numbers being ported (a supported port-in country)." }
- { name: numberType, in: query, required: false, schema: { type: string, enum: [local, mobile, national, toll_free], default: local }, description: "The portability check's phoneNumberType. Requirements differ by type." }
responses:
'200':
description: Requirement fields for the country/type combination.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
numberType: { type: string }
supported: { type: boolean, description: "false when the combination includes a step that can't be completed through the API (e.g. an in-person identity verification). Porting it needs support." }
fields:
type: array
items:
type: object
properties:
requirementId: { type: string, description: "Pass back as requirements[].requirementTypeId." }
label: { type: string }
kind: { type: string, enum: [text, date, address, file, action], description: "text/date take a string value; file takes a documentId from the documents endpoint; address is satisfied automatically from the end-user service address." }
description: { type: string }
example: { type: string }
acceptableValues: { type: array, items: { type: string }, description: "When present, the value must be one of these." }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'422': { description: "Country not supported for port-in" }
/v1/phone-numbers/port-in/{id}/requirements:
get:
x-resource-group: "telephony"
operationId: getPhoneNumberPortInOrderRequirements
tags: [Phone Numbers]
summary: A port-in order's pending requirements
description: |
The live requirements on an EXISTING porting order: which are filled,
which are still pending, and which bounced on review
(`requirement-info-exception`). Use it to fix and resubmit a rejected
international port. Same field shape as the country-level requirements
endpoint, plus per-requirement status.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: Porting order ID (from the port-in list). }
responses:
'200':
description: The order's requirements with statuses.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
requirements:
type: array
items:
type: object
properties:
requirementId: { type: string }
label: { type: string }
kind: { type: string, enum: [text, date, address, file, action] }
description: { type: string }
example: { type: string }
acceptableValues: { type: array, items: { type: string } }
status: { type: string, description: "requirement-info-pending | requirement-info-under-review | requirement-info-exception | approved" }
filled: { type: boolean }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Porting order not found }
/v1/phone-numbers/port-in/{id}:
delete:
x-resource-group: "telephony"
operationId: cancelPhoneNumberPortIn
tags: [Phone Numbers]
summary: Cancel a port-in
description: |
Cancel an in-flight port (wrong number, staying with the old carrier).
Only orders that haven't ported can be cancelled; a completed port is a
normal number release instead. The carrier may report `cancel-pending`
briefly while the losing carrier acknowledges; it settles to
`cancelled`.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: Porting order ID (from the port-in list). }
responses:
'200':
description: Cancel accepted (idempotent when already cancelled).
content:
application/json:
schema:
type: object
properties:
id: { type: string }
status: { type: string, enum: [draft, pending, foc_confirmed, ported, exception, cancelled] }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Porting order not found }
'409': { description: "Port already completed (release the number instead), or the carrier rejected the cancel (reason included)" }
/v1/phone-numbers/kyc/review-packet:
post:
x-resource-group: "telephony"
operationId: reviewPhoneNumberKycPacket
tags: [Phone Numbers]
summary: Pre-review a KYC packet
description: |
Advisory dry-run of a regulated-KYC packet before submitting: reviews
the exact documents the regulator will see (referenced by the ids from
POST /v1/phone-numbers/kyc/upload-document) against the declared values
and address, and returns plain-language advisories for likely decline
reasons (wrong document type, mismatched address, one-sided ID scans).
Non-blocking: advisories are warnings, submitting anyway is always
allowed, and any review failure degrades to an empty list.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [country, numberType, docs]
properties:
country: { type: string, minLength: 2, maxLength: 2 }
numberType: { type: string }
values: { type: object, additionalProperties: { type: string }, description: requirementId to declared textual value. }
address: { type: object, additionalProperties: { type: string }, description: "Declared address (street_address, locality, ...), so a mismatched proof-of-address can be flagged." }
docs:
type: array
minItems: 1
maxItems: 4
items:
type: object
required: [requirementId, documentId]
properties:
requirementId: { type: string }
documentId: { type: string, description: Id from POST /v1/phone-numbers/kyc/upload-document. }
issuedAt: { type: string, format: date, description: 'Date printed on the document (YYYY-MM-DD), for slots the regulator windows such as proof of address. The pre-submit review trusts it over its own read of the PDF.' }
responses:
'200':
description: Advisories (empty when the packet looks fine or the review was unavailable).
content:
application/json:
schema:
type: object
properties:
advisories:
type: array
items:
type: object
properties:
requirementId: { type: string }
concern: { type: string, description: One short plain-language concern about that requirement's document. }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/phone-numbers/{id}/remediate:
get:
x-resource-group: "telephony"
operationId: getPhoneNumberRemediation
tags: [Phone Numbers]
summary: Get declined requirements
description: |
For a number in `regulatory_declined`, returns ONLY the requirements the
reviewer flagged declined, as a form spec (same shape as the KYC form GET).
The customer fixes only those, because Telnyx supports correcting a declined
requirement group and re-submitting it (no new number/group). Falls back
to the full spec if the provider exposes no per-requirement flags.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: Phone number record ID. }
responses:
'200':
description: The declined requirements to fix.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
numberType: { type: string }
declineReason: { type: [string, "null"] }
fields: { type: array, items: { type: object }, description: Same field shape as GET /v1/phone-numbers/kyc. }
'400': { description: Number is not awaiting remediation }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
post:
x-resource-group: "telephony"
operationId: remediatePhoneNumber
tags: [Phone Numbers]
summary: Resubmit a declined number
description: |
Submit corrected values/documents for the declined requirement(s). We
PATCH them onto the SAME requirement group and re-submit it for approval;
the number goes `regulatory_declined` → `pending_regulatory`. No new
number and no new billing. Body shape matches the KYC submit (values /
documents / address). Send only the corrected fields.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
values: { type: object, additionalProperties: { type: string } }
documents:
type: array
items:
oneOf:
- { type: object, required: [requirementId, filename, base64], properties: { requirementId: { type: string }, filename: { type: string }, base64: { type: string } } }
- { type: object, required: [requirementId, documentId], properties: { requirementId: { type: string }, documentId: { type: string } } }
address: { type: object, description: Same shape as the KYC submit address. }
responses:
'200':
description: Re-submitted for approval.
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: resubmitted }
phoneNumber: { type: object, properties: { id: { type: string }, status: { type: string } } }
'400': { description: Number is not awaiting remediation / nothing to remediate }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
/v1/phone-numbers/{id}/remediate/reply:
post:
x-resource-group: "telephony"
operationId: replyToPhoneNumberReviewer
tags: [Phone Numbers]
summary: Reply to the regulatory reviewer
description: |
Post a free-text reply (with optional file attachments) to the reviewer
on a number awaiting remediation, for asks the structured form can't
express (e.g. "is this personal or business?"). Attachments are stored by
us and their links are added to the reviewer's comment thread (the
carrier's number order takes no loose files). A reply to a comment-style
ask moves the number back to "in review"; a reply on a formal decline is
supplementary and you must still resubmit the fix. Requires text or at
least one attachment.
parameters:
- name: id
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
text: { type: string, maxLength: 2000, description: The reply message to the reviewer. }
attachments:
type: array
maxItems: 5
description: 'Files (PDF/JPG/PNG/WEBP, max 10 MB each) whose links are added to the reply.'
items:
type: object
required: [filename, base64]
properties:
filename: { type: string }
base64: { type: string, description: Base64-encoded file bytes. }
responses:
'200':
description: Reply posted.
content:
application/json:
schema:
type: object
properties:
posted: { type: boolean }
attachments: { type: integer, description: Number of attachments uploaded. }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
'502': { description: Couldn't deliver the reply to the reviewer; retry. }
/v1/phone-numbers/{id}/remediate/respond:
post:
x-resource-group: "telephony"
operationId: respondToPhoneNumberReviewer
tags: [Phone Numbers]
summary: Respond to the regulatory reviewer (message + corrections)
description: |
Send a single response to the reviewer on a number awaiting remediation:
a free-text message and/or corrected requirement documents, in one call.
If corrections are present they are PATCHed onto the requirement group and
re-submitted (the number goes back to "in review"); if a message or file
attachments are present they are posted to the reviewer's comment thread.
When both are present, your message is the thread comment and the resubmit
drives the state change. At least one of message, corrections, or
attachments is required. `documents` correct requirement slots; `attachments`
are loose files (their links are added to your message).
parameters:
- name: id
in: path
required: true
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
message: { type: string, maxLength: 2000, description: Your message to the reviewer. }
documents:
type: array
description: 'Corrected requirement documents, each keyed to its requirement.'
items:
type: object
required: [requirementId]
properties:
requirementId: { type: string }
filename: { type: string }
base64: { type: string, description: Base64-encoded file bytes (or supply documentId instead). }
documentId: { type: string, description: Id of a document already uploaded out-of-band. }
issuedAt: { type: string, format: date, description: 'Date printed on the document (YYYY-MM-DD), for slots the regulator windows such as proof of address. The pre-submit review trusts it over its own read of the PDF.' }
address:
type: object
description: A corrected address record, keyed to its requirement.
entityType:
type: [string, 'null']
enum: [individual, business, null]
attachments:
type: array
maxItems: 5
description: 'Loose files (PDF/JPG/PNG/WEBP, max 10 MB each) whose links are added to your message.'
items:
type: object
required: [filename, base64]
properties:
filename: { type: string }
base64: { type: string, description: Base64-encoded file bytes. }
responses:
'200':
description: Response sent.
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [resubmitted, replied], description: '`resubmitted` when corrections were submitted, `replied` when it was message-only.' }
posted: { type: boolean, description: Whether a message/attachments were posted to the reviewer. }
phoneNumber:
type: object
properties:
id: { type: string }
status: { type: string }
siblingsResubmitted: { type: integer, description: Other numbers on the same registration the correction fanned out to. }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
'409': { description: Number's registration is held under our own carrier registration; nothing for you to correct. }
'502': { description: Couldn't deliver your response to the reviewer; retry. }
/v1/whatsapp/phone-numbers/kyc:
get:
x-resource-group: "telephony"
operationId: getWhatsAppNumberKycForm
deprecated: true
tags: [WhatsApp Phone Numbers]
summary: Get KYC form spec
description: |
Deprecated alias of `/v1/phone-numbers/kyc`; same contract. New
integrations should use that path.
For a Tier 3/4 country, the fields the end customer must provide (Telnyx
regulatory requirements) before a number can be ordered: text, date,
address, or file (document) per requirement.
security:
- bearerAuth: []
parameters:
- { name: country, in: query, required: true, schema: { type: string } }
- { name: profileId, in: query, required: true, schema: { type: string } }
responses:
'200':
description: The KYC form spec.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
numberType: { type: string }
fields:
type: array
items:
type: object
properties:
requirementId: { type: string }
label: { type: string }
kind: { type: string, enum: [text, date, address, file, action], description: '"action" = an out-of-band verification (e.g. Onfido); not filled here, fulfilled after the order via a link.' }
description: { type: [string, "null"], description: Plain-English explanation of what to provide. }
example: { type: [string, "null"], description: Concrete example value. }
localTo: { type: [string, "null"], description: ISO country the value must be local to }
audience: { type: [string, "null"], enum: [business, individual, null], description: 'When set, the requirement applies ONLY to this end-user type: provide it for that type and OMIT it for the other (e.g. Brazil: "Cartão CNPJ" is business-only, "CPF" and "ID/Passport Copy" are personal-only). Submitting both sets makes the regulator ask whether the number is for personal or business use and stalls the review. Pass `entityType` on POST so the server drops the inapplicable set.' }
reusable:
type: [object, "null"]
description: 'Present when this account already has a reusable verification for the country (skip the form). `fromPhoneNumber`/`details` mirror the first option; `options` lists ALL reusable verifications (agencies hold one per end client), approved-first. Pass the chosen option''s `id` as `reuseOptionId` on POST. Each option''s `instant` says whether it activates in minutes (group-approved) or still queues for carrier review (1-3 days).'
properties:
available: { type: boolean }
fromPhoneNumber: { type: string }
details:
type: array
description: Human-readable summary of the verification on file (field labels + values, plus the address as one line). Best-effort. May be empty if the provider lookup fails.
items:
type: object
properties:
label: { type: string }
value: { type: string }
options:
type: array
description: One entry per distinct approved verification, newest first.
items:
type: object
properties:
id: { type: string, description: 'Opaque option id. Pass as `reuseOptionId` on POST. Stable selection key (a phone number is not unique across verifications).' }
fromPhoneNumber: { type: string, description: Display only. The number this verification was submitted for. Not a selection key. }
instant: { type: boolean, description: 'true = group-approved, a new order activates in minutes; false = documents are reused but the order still queues for carrier review (1-3 days).' }
details:
type: array
items:
type: object
properties:
label: { type: string }
value: { type: string }
documentId: { type: string, description: 'Present on document rows: the Telnyx document id. GET /v1/whatsapp/phone-numbers/kyc/document/{documentId} streams it (auth-scoped, inline PDF).' }
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
integrations should use that path.
Submit the end customer's KYC (textual values, uploaded documents,
address) for a Tier 3/4 country. Documents are streamed straight to the
number provider and are not stored by Zernio. Builds + submits a
regulatory requirement group and claims a pending_regulatory slot; the
number is ordered + activated once the provider approves (asynchronous).
A customer may hold several same-country numbers in review at once; a
double-submit of the SAME attempt is deduped via `submissionId`.
For an ID-card document requirement, carriers commonly require BOTH sides:
combine the front and back into a single file before uploading (the
dashboard does this automatically). A one-sided ID is a common decline
reason; fix it via POST /v1/whatsapp/phone-numbers/{id}/remediate.
Before submitting, call GET /v1/whatsapp/phone-numbers/availability to
check the country has deliverable inventory and, for geographic-match
countries, which area the address must be in. Otherwise the submission
can pass review yet never be assignable a number.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, country]
properties:
profileId: { type: string }
country: { type: string }
submissionId: { type: string, description: 'Idempotency token for this submission attempt. 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)" }
'409': { description: 'Either reuse was requested but no prior approved verification exists for this country, or the requested areaCode has no deliverable inventory right now (code: area_code_unavailable; pick another area and resubmit).' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/phone-numbers/kyc/upload-document:
post:
x-resource-group: "telephony"
operationId: uploadWhatsAppNumberKycDocument
deprecated: true
tags: [WhatsApp Phone Numbers]
summary: Upload a KYC document
description: |
Deprecated alias of `/v1/phone-numbers/kyc/upload-document`; same contract. New
integrations should use that path.
Upload ONE document and get back its provider document id, to reference
from POST /v1/whatsapp/phone-numbers/kyc via `documents[].documentId`.
Send the RAW file bytes as the request body (not base64); put the filename
in the `X-Filename` header. Uploading documents one-per-request keeps each
request under the ~4.5MB body limit. The document streams straight to the
number provider and is not stored by Zernio.
security:
- bearerAuth: []
parameters:
- { name: X-Filename, in: header, required: true, schema: { type: string }, description: URL-encoded original filename. }
requestBody:
required: true
content:
application/octet-stream:
schema: { type: string, format: binary }
responses:
'200':
description: Document uploaded.
content:
application/json:
schema:
type: object
properties:
documentId: { type: string, description: "Reference this id in the KYC submit's documents[].documentId." }
'400': { description: "Missing X-Filename, empty body, or file too large (over 20MB)." }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/phone-numbers/kyc/validate-address:
post:
x-resource-group: "telephony"
operationId: validateWhatsAppNumberKycAddress
deprecated: true
tags: [WhatsApp Phone Numbers]
summary: Pre-validate KYC address
description: |
Deprecated alias of `/v1/phone-numbers/kyc/validate-address`; same contract. New
integrations should use that path.
Optional early check for the address step of a Tier 4 (end-user identity)
registration: validates a postal address for deliverability BEFORE the full
KYC submit, so it can be corrected before any documents are uploaded. The
full submit (POST /v1/whatsapp/phone-numbers/kyc) re-validates the address,
so this call is purely a fast feedback path and skipping it is safe. Only
the postal address is sent (no documents, no gov-ID fields). A region
(`administrative_area`) is required by the validator; when it is omitted the
pre-check is skipped and `{ ok: true, skipped: true }` is returned (the
final submit still validates).
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [country, street_address, locality, postal_code]
properties:
country: { type: string, minLength: 2, maxLength: 2, description: ISO 3166-1 alpha-2 country code. }
street_address: { type: string }
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
provider returned usable corrections, `details.addressSuggestions` carries
them per field for a one-click "apply suggestion" card. (Flat error
envelope: `error` is the human message; `code`/`param`/`details` are
top-level siblings.)
content:
application/json:
schema:
type: object
properties:
error: { type: string, description: Human-readable message. }
type: { type: string }
code: { type: string, example: INVALID_FIELD_VALUE }
param: { type: string, example: address }
details:
type: object
properties:
addressSuggestions:
type: array
items:
type: object
properties:
field: { type: string, example: administrative_area }
label: { type: string, example: State / Province }
value: { type: string, example: Dublin }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/phone-numbers/kyc/share:
post:
x-resource-group: "telephony"
operationId: createWhatsAppNumberKycLink
deprecated: true
tags: [WhatsApp Phone Numbers]
summary: Create a hosted KYC link
description: |
Deprecated alias of `/v1/phone-numbers/kyc/share`; same contract. New
integrations should use that path.
Create a single-use, 7-day hosted KYC link that your end customer
completes WITHOUT a Zernio login. Useful when the person who holds the
ID and address is not your team. They fill the regulated verification on
a Zernio-hosted page; the number provisions under YOUR account once they
submit. Only regulated (KYC) countries are valid: a country that does not
require KYC returns 400.
White-label the page with `branding` (your company name, logo, brand
color). Supply `redirect_url` to send the end customer back to your own
site after a successful submit (completion params are appended; see
below). Listen for the `whatsapp.number.kyc_submitted` webhook to react
when the form is completed.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, country]
properties:
profileId: { type: string }
country: { type: string, minLength: 2, maxLength: 2, description: ISO 3166-1 alpha-2 country code (must be a regulated/KYC country). }
areaCode: { type: string, pattern: '^\d{1,4}$', description: 'Area code (NDC) the eventual number must be in. Hard constraint carried by the link; the end customer filling the form makes no area choice. Options come from GET /v1/phone-numbers/availability (areaOptions).' }
branding:
type: object
description: Optional white-label of the hosted page the end customer sees.
properties:
companyName: { type: string, maxLength: 60, description: 'Your company name, shown on the hosted page.' }
logoUrl: { type: string, format: uri, description: Logo shown above the form. }
brandColor: { type: string, pattern: '^#[0-9a-fA-F]{6}$', description: 'Hex color (e.g. #1a73e8) used as a brand accent on the page.' }
redirect_url:
type: string
format: uri
description: |
Where to send the end customer's browser after a successful
submit. On completion Zernio appends `kyc=submitted` and
`country=<ISO-2>` as query params. When omitted, the hosted
page shows a built-in confirmation screen instead.
responses:
'200':
description: Hosted KYC link created.
content:
application/json:
schema:
type: object
properties:
url: { type: string, description: The hosted link to send your end customer. }
token: { type: string }
expiresAt: { type: string, format: date-time }
'400': { description: Country does not require KYC (not a regulated country). }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/phone-numbers/{id}/profile:
patch:
x-resource-group: "telephony"
operationId: moveWhatsAppNumberToProfile
tags: [WhatsApp Phone Numbers]
summary: Move a number to another profile
description: |
Move a provisioned number to a different profile.
A number is not a single record. Alongside the number itself there are
hidden telephony owner accounts (platform `phone`, plus `sms` when SMS is
enabled) and, once WhatsApp is connected, the `whatsapp` account. They all
carry a profileId and this endpoint moves them together.
Use this instead of `PATCH /v1/accounts/{accountId}`: that one moves the
account only and leaves the number itself pinned to its original
profile, which splits the number across two profiles. Connecting a
Zernio-provisioned number from any profile but its own is rejected with a
`409` (`WHATSAPP_NUMBER_PINNED_TO_PROFILE`). This endpoint is how you
re-home the number first, so it can then be connected from the new profile.
`id` is the number record id from `GET /v1/phone-numbers`, not an account id.
A profile holds at most one account per platform, so the destination must be
free of every platform this number occupies.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: WhatsAppPhoneNumber id. }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId]
properties:
profileId: { type: string, description: Destination profile id. Must belong to the same team. }
responses:
'200':
description: Number moved, or already on that profile.
content:
application/json:
schema:
type: object
properties:
message: { type: string }
profileId: { type: string, description: The profile the number is now on. }
movedPlatforms:
type: array
items: { type: string }
description: 'Platforms whose accounts travelled with the number (phone, sms, whatsapp). Absent when the number was already on the destination profile.'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'No access to the source or destination profile, or the Inbox add-on is not active.' }
'404': { description: 'Number not found, or the destination profile does not exist.' }
'409': { description: 'The destination profile already holds an account on one of the platforms this number occupies.' }
/v1/whatsapp/phone-numbers/{id}/remediate:
get:
x-resource-group: "telephony"
operationId: getWhatsAppNumberRemediation
deprecated: true
tags: [WhatsApp Phone Numbers]
summary: Get declined requirements
description: |
Deprecated alias of `/v1/phone-numbers/{id}/remediate`; same contract. New
integrations should use that path.
For a number in `regulatory_declined`, returns ONLY the requirements the
reviewer flagged declined, as a form spec (same shape as the KYC form GET).
The customer fixes only those, because Telnyx supports correcting a declined
requirement group and re-submitting it (no new number/group). Falls back
to the full spec if the provider exposes no per-requirement flags.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: WhatsAppPhoneNumber id. }
responses:
'200':
description: The declined requirements to fix.
content:
application/json:
schema:
type: object
properties:
country: { type: string }
numberType: { type: string }
declineReason: { type: [string, "null"] }
fields: { type: array, items: { type: object }, description: Same field shape as GET /v1/whatsapp/phone-numbers/kyc. }
'400': { description: Number is not awaiting remediation }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
post:
x-resource-group: "telephony"
operationId: remediateWhatsAppNumber
deprecated: true
tags: [WhatsApp Phone Numbers]
summary: Resubmit a declined number
description: |
Deprecated alias of `/v1/phone-numbers/{id}/remediate`; same contract. New
integrations should use that path.
Submit corrected values/documents for the declined requirement(s). We
PATCH them onto the SAME requirement group and re-submit it for approval;
the number goes `regulatory_declined` → `pending_regulatory`. No new
number and no new billing. Body shape matches the KYC submit (values /
documents / address). Send only the corrected fields.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
values: { type: object, additionalProperties: { type: string } }
documents:
type: array
items:
oneOf:
- { type: object, required: [requirementId, filename, base64], properties: { requirementId: { type: string }, filename: { type: string }, base64: { type: string } } }
- { type: object, required: [requirementId, documentId], properties: { requirementId: { type: string }, documentId: { type: string } } }
address: { type: object, description: Same shape as the KYC submit address. }
responses:
'200':
description: Re-submitted for approval.
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: resubmitted }
phoneNumber: { type: object, properties: { id: { type: string }, status: { type: string } } }
'400': { description: Number is not awaiting remediation / nothing to remediate }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
/v1/phone-numbers/{id}/voice:
post:
x-resource-group: "telephony"
operationId: enableVoiceOnNumber
tags: [Voice]
summary: Enable phone calling on a number
description: |
Turns on regular phone (PSTN) calling for one of your numbers and
configures how inbound calls are handled. Inbound calls route to
`forwardTo`: your own AI voice agent (Vapi/Retell), a phone, or a SIP
endpoint. Optional extras: voicemail, business-hours windows, an IVR
menu, a caller blocklist, recording, and transcription. A number can
also be voice-enabled with no forward (outbound-only).
Idempotent, and doubles as the settings update: only fields present in
the body are written. Omitting `forwardTo` preserves the current
destination; sending an empty string clears it.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: "Phone number record ID (from GET /v1/phone-numbers)." }
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
forwardTo: { type: string, description: "tel:+E164, sip:..., or wss://... destination for inbound calls. Empty string clears the forward (outbound-only); omitted preserves the current one." }
recordingEnabled: { type: boolean }
transcriptionEnabled: { type: boolean }
transcriptionLanguage: { type: string, enum: [auto, en, es] }
voicemailEnabled: { type: boolean, description: Voicemail is taken when there's no live destination. Default on. }
voicemailGreeting: { type: string, maxLength: 1000, description: Custom spoken greeting; empty string restores the default. }
businessHoursEnabled: { type: boolean, description: "Outside the windows, inbound skips the forward and goes to voicemail. Off = 24/7." }
businessHoursTimezone: { type: string, maxLength: 64, description: IANA timezone the windows are evaluated in. }
businessHours:
type: array
maxItems: 21
items:
type: object
required: [day, open, close]
properties:
day: { type: integer, minimum: 0, maximum: 6, description: 0 = Sunday. }
open: { type: string, pattern: '^\d{2}:\d{2}$' }
close: { type: string, pattern: '^\d{2}:\d{2}$' }
blockedCallers:
type: array
maxItems: 1000
items: { type: string }
description: E.164 numbers rejected before answer. Replaces the whole list; bare 10-digit values are normalized as US numbers.
forwardCallerId: { type: string, enum: [business, caller], description: "Caller ID on the forwarded leg: your number (`business`) or the original caller's (`caller`)." }
ivrEnabled: { type: boolean, description: IVR menu (supersedes the plain forward within business hours). }
ivrPrompt: { type: string, maxLength: 1000 }
ivrOptions:
type: array
maxItems: 12
items:
type: object
required: [digit, forwardTo]
properties:
digit: { type: string, pattern: '^[0-9*#]$' }
forwardTo: { type: string, description: "tel:+E164, sip:..., or wss://... destination for this digit." }
label: { type: string, maxLength: 80 }
responses:
'200':
description: Voice enabled; the full effective voice config is echoed back.
content:
application/json:
schema:
type: object
properties:
enabled: { type: boolean }
phoneNumber: { type: string }
pstnForwardTo: { type: [string, "null"] }
recordingEnabled: { type: boolean }
transcriptionEnabled: { type: boolean }
transcriptionLanguage: { type: string, enum: [auto, en, es] }
voicemailEnabled: { type: boolean }
voicemailGreeting: { type: [string, "null"] }
businessHoursEnabled: { type: boolean }
businessHoursTimezone: { type: [string, "null"] }
businessHours: { type: array, items: { type: object, properties: { day: { type: integer }, open: { type: string }, close: { type: string } } } }
blockedCallers: { type: array, items: { type: string } }
forwardCallerId: { type: string, enum: [business, caller] }
ivrEnabled: { type: boolean }
ivrPrompt: { type: [string, "null"] }
ivrOptions: { type: array, items: { type: object, properties: { digit: { type: string }, forwardTo: { type: string }, label: { type: string } } } }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
'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
and settings are preserved, so re-enabling restores the prior config.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Voice disabled.
content:
application/json:
schema:
type: object
properties:
enabled: { type: boolean, description: Always false after a successful disable. }
phoneNumber: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
/v1/phone-numbers/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
numbers again: an email to the account holder plus the
`phone_number.stock_available` webhook. Stock is re-checked every 6h.
One watch per country and number type; a repeat request returns the
existing watch (200). The watch is consumed when it fires, so re-create
it if you miss the stock. Up to 20 watches at once.
Countries and types marked `fulfilment: request` by
GET /v1/phone-numbers/countries can also be watched, but anything with
`preOrderable: true` does not need a watch: submit KYC and the carrier
sources the number to order.
Pass `areaCode` (with `numberType`) to watch one sold-out area, for
example an entry of `soldOutAreas` from
GET /v1/phone-numbers/availability. Area stock is checked live on the
same 6h cadence.
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:
'201':
description: Watch created.
content:
application/json:
schema:
$ref: '#/components/schemas/PhoneNumberStockWatch'
'200':
description: 'A watch for this country and type already existed; returned unchanged.'
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,
Vapi, or any SIP endpoint) can import your Zernio numbers into. The
trunk carries both directions: inbound calls on attached numbers are
delivered to `sipHost`, and the platform originates outbound calls
through `termination.uri` with the digest credentials.
The `digestPassword` is returned only by this call (and by
rotate-credentials); store it immediately. Attach any number of numbers
to a trunk. Several trunks may point at the same host. Each carries its
own credentials and spend cap, so separate destinations (e.g.
an agency's clients) stay isolated.
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
number is still attached: detach them first.
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
working immediately, so update the destination platform right away.
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
its inbound directly and can present it as outbound caller ID. While
attached, Zernio-side voice features are off for this number (call
forwarding, IVR, voicemail, recording, the softphone, and WhatsApp
calling), so the number must have Calls and WhatsApp calling disabled
before attaching. SMS and WhatsApp messaging are unaffected.
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
number is not attached to any trunk.
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
capability is checked first: some number types can't do SMS at all
(`smsCapable: false`), and a number still provisioning at the carrier
returns `notReady: true` (try again once provisioning finishes).
US numbers additionally need a carrier registration before messages
deliver; the response tells you which path applies:
- `alreadyRegistered: true`: a prior registration still covers this
number; SMS was reactivated.
- `reusable` set: you have an approved registration this number can
join in one click via
`POST /v1/phone-numbers/{id}/sms/reuse-registration`
(no new brand/campaign, no extra carrier fee).
- `needsRegistration: true` and no `reusable`: start one via
`POST /v1/sms/registrations`.
Idempotent: re-running re-attempts any carrier-side setup that failed.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: "Phone number record ID (from GET /v1/phone-numbers)." }
responses:
'200':
description: "Result. Check `enabled`: a 200 with `enabled: false` means the number can't do SMS (`smsCapable: false`) or isn't ready yet (`notReady: true`)."
content:
application/json:
schema:
type: object
properties:
enabled: { type: boolean }
id: { type: string, description: The SMS account ID (present when enabled). }
phoneNumber: { type: string }
isActive: { type: boolean, description: False for US numbers until their registration is approved. }
country: { type: string }
smsCapable: { type: [boolean, "null"], description: Null when capability can't be read yet (still provisioning). }
mmsCapable: { type: boolean }
domesticOnly: { type: boolean }
notReady: { type: boolean, description: Number is still provisioning at the carrier; retry shortly. }
needsRegistration: { type: boolean, description: US only; a carrier registration is required before delivery. }
alreadyRegistered: { type: boolean, description: A prior non-rejected registration already covers this number; no re-submit needed. }
registrationStatus: { type: [string, "null"], enum: [pending, approved, rejected, null] }
reusable:
type: [object, "null"]
description: Present when an existing approved registration can cover this number via /sms/reuse-registration.
properties:
registrationId: { type: string }
status: { type: string }
message: { type: string, description: Human-readable explanation when `enabled` is false. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
'422': { description: "This number is hosted by your own carrier (brought via WhatsApp embedded signup), so SMS can't be enabled on it." }
delete:
x-resource-group: "telephony"
operationId: disableSmsOnNumber
tags: [SMS]
summary: Disable SMS on a number
description: |
Turns off SMS for the number (deactivates its SMS account). The carrier
registration is untouched, so re-enabling later reactivates it,
with no re-registration.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: SMS disabled.
content:
application/json:
schema:
type: object
properties:
enabled: { type: boolean, description: Always false after a successful disable. }
phoneNumber: { type: string }
disabled: { type: boolean, description: False when SMS was already off. Legacy field; prefer `enabled`. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
/v1/phone-numbers/{id}/sms/reuse-registration:
post:
x-resource-group: "telephony"
operationId: reuseSmsRegistrationForNumber
tags: [SMS]
summary: Add number to SMS registration
description: |
Attaches this number to your existing approved 10DLC campaign instead
of running a fresh registration: the number inherits the campaign's
approval (no new brand or campaign, no extra carrier fee). Enable SMS
on the number first (`POST /v1/phone-numbers/{id}/sms`; its response
tells you whether a reusable registration exists).
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Number added to the existing registration.
content:
application/json:
schema:
type: object
properties:
registrationId: { type: string }
status: { type: string, enum: [pending, approved, rejected, requested, changes_requested, deactivated], description: "requested/changes_requested = pre-submission review states; customers see them as pending / needs changes." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
'409': { description: No existing SMS registration to reuse for this number }
/v1/phone-numbers/{id}/whatsapp/calling:
get:
x-resource-group: "telephony"
operationId: getWhatsAppCalling
tags: [WhatsApp Calling]
summary: Get calling config for a number
description: |
The WhatsApp Business Calling configuration of this number, keyed the
same way as the POST/PATCH/DELETE below (full read-write on one
sub-resource). Encrypted secrets are never returned; only a boolean
saying whether a SIP password is stored. The account-scoped read
(`GET /v1/whatsapp/calling?accountId=`) remains for callers that only
know the account id, and additionally carries account-level
extras (billing eligibility, current-period spend).
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: "Phone number record ID (from GET /v1/phone-numbers)." }
responses:
'200':
description: Calling config
content:
application/json:
schema:
type: object
properties:
phoneNumber: { type: string }
callingEnabled: { type: boolean }
callDeepLink: { type: [string, "null"], description: "Public calling deep link (https://wa.me/call/<number>). Null while calling is disabled." }
forwardTo: { type: [string, "null"], description: "tel:+E164 / sip:... / wss://... destination" }
recordingEnabled: { type: boolean }
sipAuthUsername: { type: [string, "null"] }
sipAuthPasswordConfigured: { type: boolean, description: True when a SIP digest password is stored. The plaintext is never returned. }
callIconCountries:
type: [array, "null"]
items: { type: string, minLength: 2, maxLength: 2 }
outboundDisabled: { type: boolean, description: "True when the number's country blocks business-initiated (outbound) WhatsApp calling; inbound still works." }
callerIdMode:
type: string
enum: [business, platform]
description: 'Caller ID the forward-leg callee sees on tel: forwards. business = this WhatsApp number; platform = a Zernio number (used when the number was brought by the customer and its caller ID is not verified for PSTN origination).'
callerIdVerified: { type: boolean, description: 'True once the number completed caller-ID verification, making tel: forwards display the business number itself.' }
maxCallDurationSeconds: { type: [integer, "null"], description: 'Hard cap (seconds) on forwarded calls; null = no cap.' }
forwardCallerId: { type: string, enum: [business, caller] }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
post:
x-resource-group: "telephony"
operationId: enableWhatsAppCalling
tags: [WhatsApp Calling]
summary: Enable calling on a number
description: |
Enable WhatsApp Business Calling on a connected number. Configures
Meta calling.status=ENABLED with our Telnyx SIP endpoint, fetches and
stores the Meta-issued SIP password (encrypted), and snapshots the
customer's forward-to destination.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: "Phone number record ID (from GET /v1/phone-numbers)." }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, forwardTo]
properties:
accountId: { type: string }
forwardTo: { type: string, description: "tel:+E164 / sip:... / wss://... destination" }
sipAuthUsername: { type: string }
sipAuthPassword: { type: string, description: "Stored encrypted, never returned by any endpoint." }
recordingEnabled: { type: boolean, default: false }
callIconCountries:
type: array
items: { type: string, minLength: 2, maxLength: 2 }
maxCallDurationSeconds: { type: integer, minimum: 30, maximum: 14400, description: 'Hard cap (seconds) on a forwarded call; the carrier hangs up both legs when it fires. Safety valve against dead-air billing when a destination hangs up but the signal is lost.' }
forwardCallerId: { type: string, enum: [business, caller], default: business, description: 'Caller ID presented to the forward destination. caller = the WhatsApp user''s number (sip: destinations only; ignored on tel: forwards). Fixes AI-agent trunks that reject seeing the business number call itself.' }
responses:
'200':
description: Calling enabled
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
callingEnabled: { type: boolean }
sipHostname: { type: string }
forwardTo: { type: string }
callerIdMode:
type: string
enum: [business, platform]
description: 'Caller ID the forward-leg callee sees on tel: forwards. business = this WhatsApp number; platform = a Zernio number (customer-brought number without verified caller ID).'
'400': { description: 'Invalid request (including forwardTo set to the number itself)' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Phone number not found }
'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
the body are written; `undefined` leaves the stored value alone,
explicit `null` clears a nullable field. No Meta side effect, this
only changes local routing state consumed by the Telnyx webhook
handler.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string }
forwardTo: { type: string }
sipAuthUsername: { type: [string, "null"] }
sipAuthPassword: { type: [string, "null"] }
recordingEnabled: { type: boolean }
callIconCountries:
type: [array, "null"]
items: { type: string, minLength: 2, maxLength: 2 }
maxCallDurationSeconds: { type: [integer, "null"], minimum: 30, maximum: 14400, description: 'Hard cap (seconds) on forwarded calls; null clears the cap.' }
forwardCallerId: { type: string, enum: [business, caller], description: 'caller = present the WhatsApp user''s number to the forward destination (sip: only).' }
responses:
'200': { description: Updated }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Phone number not found }
'422': { description: Calling must be enabled before settings can be updated }
delete:
x-resource-group: "telephony"
operationId: disableWhatsAppCalling
tags: [WhatsApp Calling]
summary: Disable calling on a number
description: |
Disable calling. Sends calling.status=DISABLED to Meta (best-effort)
and flips the local `callingEnabled` flag off. forwardTo and SIP
creds are preserved so a re-enable does not lose the destination.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string } }
responses:
'200': { description: Disabled }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Phone number not found }
/v1/phone-numbers/{id}/whatsapp/caller-id-verification:
post:
x-resource-group: "telephony"
operationId: startWhatsAppCallerIdVerification
tags: [WhatsApp Calling]
summary: Start caller-ID verification for a customer-brought number
description: |
Customer-brought (BYO) WhatsApp numbers cannot present themselves as
caller ID on `tel:` call forwards until verified (carrier
anti-spoofing); until then forwarded calls show a Zernio number
(`callerIdMode: platform` on the calling config). This sends a
one-time code to the number by SMS or voice call. Re-POST to resend.
Zernio-purchased numbers never need this and get a 400.
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: "Phone number record ID (from GET /v1/phone-numbers)." }
requestBody:
required: false
content:
application/json:
schema:
type: object
properties:
method: { type: string, enum: [sms, call], default: sms }
responses:
'200':
description: 'Code sent (or the number was already verified)'
content:
application/json:
schema:
type: object
properties:
verified: { type: boolean }
codeSent: { type: boolean }
method: { type: string, enum: [sms, call] }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
'429': { description: 'Too many verification attempts for this number; wait before retrying' }
/v1/phone-numbers/{id}/whatsapp/caller-id-verification/verify:
post:
x-resource-group: "telephony"
operationId: verifyWhatsAppCallerId
tags: [WhatsApp Calling]
summary: Confirm the caller-ID verification code
description: |
Submits the one-time code the number received. On success, `tel:`
call forwards present the business number itself as caller ID
(`callerIdMode: business`).
security:
- bearerAuth: []
parameters:
- { name: id, in: path, required: true, schema: { type: string }, description: "Phone number record ID (from GET /v1/phone-numbers)." }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code]
properties:
code: { type: string, minLength: 4, maxLength: 10 }
responses:
'200':
description: Verified
content:
application/json:
schema:
type: object
properties:
verified: { type: boolean }
'400': { description: 'Invalid or expired code, or malformed request' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Number not found }
'429': { description: 'Attempt lockout from the carrier; wait a few minutes, then request a fresh code' }
/v1/whatsapp/phone-numbers/{phoneNumberId}:
get:
x-resource-group: "telephony"
operationId: getWhatsAppPhoneNumber
deprecated: true
tags: [WhatsApp Phone Numbers]
summary: Get phone number
description: |
Deprecated alias of `/v1/phone-numbers/{id}`; same contract. New
integrations should use that path.
Retrieve the current status of a purchased phone number. Poll this to
track Meta pre-verification (US sync path) and, for regulated (Tier 3/4)
numbers, the async lifecycle: pending_regulatory → active (or
regulatory_declined). When a regulated number has an Onfido ID step,
`onfidoVerificationUrl` appears here once the order is placed. Forward
it to the end user. (Or subscribe to the whatsapp.number.* webhooks
instead of polling.)
security:
- bearerAuth: []
parameters:
- name: phoneNumberId
in: path
required: true
description: Phone number record ID
schema:
type: string
responses:
'200':
description: Phone number retrieved successfully
content:
application/json:
schema:
type: object
properties:
phoneNumber:
type: object
properties:
id: { type: string }
phoneNumber: { type: string }
status: { type: string, enum: [pending_payment, pending_regulatory, regulatory_declined, provisioning, verifying, active, suspended, releasing, released] }
country: { type: string }
metaPreverifiedId: { type: string }
metaVerificationStatus: { type: string }
onfidoVerificationUrl: { type: [string, "null"], description: "For a regulated number with an Onfido ID step: the link to forward to the end user. Appears once the order is placed; null otherwise." }
endUserFirstName: { type: [string, "null"] }
endUserLastName: { type: [string, "null"] }
regulatoryDeclineReason: { type: [string, "null"], description: Reviewer rejection reason when status is regulatory_declined. }
provisionedAt: { type: string, format: date-time }
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
integrations should use that path.
Release a purchased phone number. This will:
1. Disconnect any linked WhatsApp account
2. Decrement the Stripe subscription quantity (or cancel if last number)
3. Release the number from Telnyx
4. Mark the number as released
security:
- bearerAuth: []
parameters:
- name: phoneNumberId
in: path
required: true
description: Phone number record ID
schema:
type: string
responses:
'200':
description: Phone number released successfully
content:
application/json:
schema:
type: object
properties:
message: { type: string }
phoneNumber:
type: object
properties:
id: { type: string }
phoneNumber: { type: string }
status: { type: string, description: "\"released\"" }
releasedAt: { type: string, format: date-time }
'400': { description: Phone number is already released or being released }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
'409': { description: 'The number is attached to a SIP trunk; detach it first (code invalid_resource_state).' }
# ─── WhatsApp Sandbox (shared test number) ──────────────────────
#
# The sandbox is one Zernio-owned WhatsApp number every user can test
# against without owning their own number. To prevent abuse of the
# verified WABA, each user must activate the recipient phone first:
# we send a verified template TO their phone; they reply (any text);
# the inbound webhook flips the session to `active`. Only then can
# the user fire the locked sandbox template at that phone via the
# standard inbox compose endpoints.
#
# Limits per user: 50 messages / 24h, 5 distinct recipients / 24h
# (effectively 1, one phone per user). Pending sessions expire after
# 24h; activated sessions after 7 days.
/v1/whatsapp/sandbox/sessions:
get:
x-resource-group: "messages"
operationId: listWhatsAppSandboxSessions
tags: [WhatsApp Sandbox]
summary: List your sandbox sessions
description: |
Returns all of the authenticated user's non-expired sandbox sessions
(pending + active) plus the sandbox phone number. In practice there
is at most one session per user since the sandbox is one-phone-per-user;
the array shape is preserved for forward compatibility.
security:
- bearerAuth: []
responses:
'200':
description: Sessions retrieved successfully
content:
application/json:
schema:
type: object
properties:
sessions:
type: array
items: { $ref: '#/components/schemas/WhatsAppSandboxSession' }
sandboxNumber:
type: [string, "null"]
description: The shared sandbox phone number in E.164 form.
example: "+12029087457"
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Inbox addon required }
post:
x-resource-group: "messages"
operationId: createWhatsAppSandboxSession
tags: [WhatsApp Sandbox]
summary: Start a sandbox activation
description: |
Creates (or refreshes) a pending sandbox session for the given phone and
immediately fires the verified sandbox template from the shared sandbox
number to that phone. The session activates when the phone owner replies
to that WhatsApp message: the reply itself is proof of ownership.
One phone per user: if the caller already has a non-expired session for
a DIFFERENT phone, the request is rejected with `invalid_field_value`
(the message names the existing phone so it can be revoked first).
Re-creating a session for the SAME phone is idempotent and refreshes
the verification template.
If Meta rejects the template send (not a WhatsApp number, paused WABA,
token issue), the pending row is rolled back and the Meta error message
is returned in `error` so the caller knows why.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [phone]
properties:
phone:
type: string
description: Recipient phone in international format. Digits, spaces, dashes and a leading `+` are all accepted; the server normalizes to E.164 digits-only.
example: "+34688246216"
responses:
'200':
description: Session created or refreshed; verification template sent
content:
application/json:
schema:
type: object
properties:
session: { $ref: '#/components/schemas/WhatsAppSandboxSession' }
sandboxNumber: { type: string, example: "+12029087457" }
'400':
description: |
Returned when (a) phone format is invalid, (b) phone equals the sandbox
number itself, (c) the user already has a session for a different phone,
or (d) Meta rejected the template send. The `error` field contains the
specific reason; `param` is set when a field is at fault.
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Inbox addon required }
/v1/whatsapp/sandbox/sessions/{sessionId}:
delete:
x-resource-group: "messages"
operationId: deleteWhatsAppSandboxSession
tags: [WhatsApp Sandbox]
summary: Revoke a sandbox session
description: |
Hard-deletes the session. The user loses the ability to send to that
phone via the sandbox until they re-activate it. Existing conversations
and messages already exchanged with that phone are untouched.
Revocation only blocks FUTURE sends.
Sessions belonging to other users cannot be revoked; the response is
the same 400 as "session not found" so existence isn't leaked.
security:
- bearerAuth: []
parameters:
- name: sessionId
in: path
required: true
description: The session id returned by POST /v1/whatsapp/sandbox/sessions.
schema: { type: string }
responses:
'200':
description: Session revoked
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
'400': { description: Invalid or unknown session id }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Inbox addon required }
# ─── WhatsApp Group Chats (platform groups, not contact groups) ──
/v1/whatsapp/wa-groups:
get:
x-resource-group: "messages"
operationId: listWhatsAppGroupChats
tags: [WhatsApp]
summary: List active groups
description: |
List active WhatsApp group chats for a business phone number.
These are actual WhatsApp group conversations on the platform.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
- { name: limit, in: query, schema: { type: integer, default: 25, maximum: 1024 }, description: Max groups to return }
- { name: after, in: query, schema: { type: string }, description: Pagination cursor }
responses:
'200':
description: List of active groups
content:
application/json:
schema:
type: object
properties:
groups:
type: array
items:
type: object
properties:
id: { type: string, description: Group ID }
subject: { type: string, description: Group name }
createdAt: { type: string, description: Group creation timestamp }
paging:
type: object
properties:
cursors:
type: object
properties:
after: { type: string }
before: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
x-resource-group: "messages"
operationId: createWhatsAppGroupChat
tags: [WhatsApp]
summary: Create group
description: |
Create a new WhatsApp group chat. Returns the group ID and optionally an invite link.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, subject]
properties:
accountId: { type: string, description: WhatsApp account ID }
subject: { type: string, maxLength: 128, description: Group name (max 128 characters) }
description: { type: string, maxLength: 2048, description: Group description (max 2048 characters) }
joinApprovalMode:
type: string
enum: [approval_required, auto_approve]
description: Whether users need approval to join via invite link
responses:
'201':
description: Group created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
group:
type: object
properties:
groupId: { type: string }
inviteLink: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/wa-groups/{groupId}:
get:
x-resource-group: "messages"
operationId: getWhatsAppGroupChat
tags: [WhatsApp]
summary: Get group info
description: |
Retrieve metadata about a WhatsApp group including subject, description,
participants, and settings.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
responses:
'200':
description: Group info
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
group:
type: object
properties:
id: { type: string }
subject: { type: string }
description: { type: string }
joinApprovalMode: { type: string }
participants:
type: array
items:
type: object
properties:
user: { type: string, description: Phone number }
admin: { type: string }
participantCount: { type: integer }
createdAt: { type: integer, description: UNIX timestamp }
isSuspended: { type: boolean }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
post:
x-resource-group: "messages"
operationId: updateWhatsAppGroupChat
tags: [WhatsApp]
summary: Update group settings
description: |
Update the subject, description, or join approval mode of a WhatsApp group.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
subject: { type: string, maxLength: 128 }
description: { type: string, maxLength: 2048 }
joinApprovalMode: { type: string, enum: [approval_required, auto_approve] }
responses:
'200':
description: Group updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
x-resource-group: "messages"
operationId: deleteWhatsAppGroupChat
tags: [WhatsApp]
summary: Delete group
description: |
Delete a WhatsApp group and remove all participants.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp 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.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [phoneNumbers]
properties:
phoneNumbers:
type: array
maxItems: 8
items: { type: string }
description: Phone numbers in E.164 format (max 8)
responses:
'200':
description: Participants added
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
delete:
x-resource-group: "messages"
operationId: removeWhatsAppGroupParticipants
tags: [WhatsApp]
summary: Remove participants
description: |
Remove participants from a WhatsApp group.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [phoneNumbers]
properties:
phoneNumbers:
type: array
items: { type: string }
description: Phone numbers to remove
responses:
'200':
description: Participants removed
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/wa-groups/{groupId}/invite-link:
post:
x-resource-group: "messages"
operationId: createWhatsAppGroupInviteLink
tags: [WhatsApp]
summary: Create invite link
description: |
Create a new invite link for a WhatsApp group. The previous link is revoked.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
responses:
'200':
description: Invite link created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
inviteLink: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/whatsapp/wa-groups/{groupId}/join-requests:
get:
x-resource-group: "messages"
operationId: listWhatsAppGroupJoinRequests
tags: [WhatsApp]
summary: List join requests
description: |
List pending join requests for a WhatsApp group (only for groups with approval_required mode).
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
responses:
'200':
description: Join requests
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
joinRequests:
type: array
items:
type: object
properties:
user: { type: string, description: Phone number }
timestamp: { type: integer, description: UNIX timestamp of request }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
x-resource-group: "messages"
operationId: approveWhatsAppGroupJoinRequests
tags: [WhatsApp]
summary: Approve join requests
description: |
Approve pending join requests for a WhatsApp group.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [phoneNumbers]
properties:
phoneNumbers:
type: array
items: { type: string }
description: Phone numbers to approve
responses:
'200':
description: Requests approved
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
delete:
x-resource-group: "messages"
operationId: rejectWhatsAppGroupJoinRequests
tags: [WhatsApp]
summary: Reject join requests
description: |
Reject pending join requests for a WhatsApp group.
Not available on [Coexistence](/platforms/whatsapp/connection#whatsapp-business-app-coexistence) numbers. Requires a Cloud API-only number.
security:
- bearerAuth: []
parameters:
- { name: groupId, in: path, required: true, schema: { type: string }, description: Group ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [phoneNumbers]
properties:
phoneNumbers:
type: array
items: { type: string }
description: Phone numbers to reject
responses:
'200':
description: Requests rejected
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
message: { type: string }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
# ─── WhatsApp Flows ───────────────────────────────────────────────
/v1/whatsapp/flows:
get:
x-resource-group: "accounts"
operationId: listWhatsAppFlows
tags: [WhatsApp Flows]
summary: List flows
description: |
List all WhatsApp Flows for the Business Account (WABA) associated with the given account.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
responses:
'200':
description: Flows retrieved
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
flows:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
status: { type: string, enum: [DRAFT, PUBLISHED, DEPRECATED, BLOCKED, THROTTLED] }
categories:
type: array
items: { type: string }
validation_errors:
type: array
items: { type: object }
version:
type: integer
description: 1-based version within the flow's clone lineage (Zernio-tracked; Meta has no native versioning). Standalone flows are version 1.
lineageId:
type: string
description: Stable group key for the flow's version lineage (the root flow's ID).
'400': { description: WABA ID not found on account }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
post:
x-resource-group: "accounts"
operationId: createWhatsAppFlow
tags: [WhatsApp Flows]
summary: Create flow
description: |
Create a new WhatsApp Flow in DRAFT status. Optionally clone an existing flow.
After creating, upload a Flow JSON definition, then publish to make it sendable.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, name, categories]
properties:
accountId: { type: string, description: WhatsApp 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.
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.
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.
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.
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,
components (text inputs, dropdowns, date pickers, etc.), and navigation.
Meta validates the JSON on upload and returns any validation errors.
See: https://developers.facebook.com/docs/whatsapp/flows/reference/flowjson
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, flow_json]
properties:
accountId: { type: string, description: WhatsApp account ID }
flow_json:
description: "The Flow JSON content. Pass as a JSON object or a JSON string."
oneOf:
- type: object
- type: string
examples:
simple_form:
summary: Simple lead capture form
value:
accountId: "507f1f77bcf86cd799439011"
flow_json:
version: "6.0"
screens:
- id: "LEAD_FORM"
title: "Get a Quote"
terminal: true
success: true
layout:
type: "SingleColumnLayout"
children:
- type: "TextInput"
name: "full_name"
label: "Full Name"
required: true
input-type: "text"
- type: "TextInput"
name: "email"
label: "Email"
required: true
input-type: "email"
- type: "Footer"
label: "Submit"
on-click-action:
name: "complete"
payload:
full_name: "${form.full_name}"
email: "${form.email}"
responses:
'200':
description: Flow JSON uploaded
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
validation_errors:
type: array
description: "Empty array if valid; otherwise, contains validation error details from Meta"
items:
type: object
properties:
error: { type: string }
error_type: { type: string }
message: { type: string }
line_start: { type: integer }
line_end: { type: integer }
column_start: { type: integer }
column_end: { type: integer }
'400': { description: Invalid JSON or flow is not in DRAFT status }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/flows/{flowId}/preview:
get:
x-resource-group: "accounts"
operationId: getWhatsAppFlowPreview
tags: [WhatsApp Flows]
summary: Get flow preview URL
description: |
Get Meta's public web-preview URL for a flow (drafts included), embeddable as an
interactive iframe. The link is reused across calls (valid ~30 days); pass
invalidate=true to mint a fresh one (the previous link stops working).
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
- { name: invalidate, in: query, required: false, schema: { type: boolean }, description: Mint a fresh preview link (default false) }
responses:
'200':
description: Preview URL
content:
application/json:
schema:
type: object
properties:
preview_url: { type: [string, "null"] }
expires_at: { type: [string, "null"] }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Flow or account not found }
/v1/whatsapp/flows/{flowId}/versions:
get:
x-resource-group: "accounts"
operationId: listWhatsAppFlowVersions
tags: [WhatsApp Flows]
summary: List flow versions
description: |
List the flow's version history (the clone lineage Zernio tracks, since Meta has no
native versioning), newest version first. Each entry is enriched with the version's
live name and status from Meta. A flow with no lineage returns only itself as version 1.
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
responses:
'200':
description: Version history
content:
application/json:
schema:
type: object
properties:
versions:
type: array
items:
type: object
properties:
flowId: { type: string }
version: { type: integer }
parentFlowId: { type: [string, "null"] }
name: { type: [string, "null"] }
status: { type: [string, "null"] }
missing: { type: boolean, description: True when Meta no longer has this flow }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Flow or account not found }
/v1/whatsapp/flows/{flowId}/publish:
post:
x-resource-group: "accounts"
operationId: publishWhatsAppFlow
tags: [WhatsApp Flows]
summary: Publish flow
description: |
Publish a DRAFT flow. This is irreversible. Once published, the flow and its JSON
become immutable and the flow can be sent to users. To update a published flow,
create a new flow (optionally cloning this one via cloneFlowId).
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: WhatsApp account ID }
responses:
'200':
description: Flow published
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
'400': { description: Flow is not in DRAFT status or has validation errors }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/flows/{flowId}/deprecate:
post:
x-resource-group: "accounts"
operationId: deprecateWhatsAppFlow
tags: [WhatsApp Flows]
summary: Deprecate flow
description: |
Deprecate a PUBLISHED flow. This is irreversible. Deprecated flows cannot be sent
or opened, but existing active sessions may continue until they complete.
security:
- bearerAuth: []
parameters:
- { name: flowId, in: path, required: true, schema: { type: string }, description: Flow ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: WhatsApp 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
endpoint encryption. Only one key is active per phone number at a time. Flows that
use flow_action: data_exchange (an endpoint-backed flow) stop working at runtime
until the endpoint serves the matching private key, and Meta rejects publish with
error code 139002 ("Missing Flows Signed Public Key") when no key is registered.
`registered` reflects whether a key is present, never `signatureStatus` alone:
Meta reports an unregistered key as MISMATCH rather than a null/absent value.
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
encryption on the phone number. Uploading a new key replaces the previous one:
only one key is active per phone number. The corresponding private key must be
served by the flow's endpoint, or endpoint-backed flows (flow_action:
data_exchange) will fail at runtime even though the key is registered.
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.
When the recipient taps the button, the flow opens natively in WhatsApp.
Flow responses are received via webhooks.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, to, flow_id, flow_cta, body]
properties:
accountId: { type: string, description: WhatsApp account ID }
to: { type: string, description: "Recipient phone number (E.164 format, e.g. +1234567890)" }
flow_id: { type: string, description: Published flow ID }
flow_cta: { type: string, maxLength: 20, description: "CTA button text (e.g. 'Book Now', 'Sign Up')" }
flow_action:
type: string
enum: [navigate, data_exchange]
default: navigate
description: "Action type: navigate opens a screen directly, data_exchange hits your endpoint first"
flow_token: { type: string, maxLength: 200, description: "Unique token to correlate responses. If omitted, auto-generated as '<flowId>:<uuid>' so the response can be attributed to this flow in the Flow Responses view." }
flow_action_payload:
type: object
properties:
screen: { type: string, description: First screen ID to navigate to }
data:
type: object
description: Optional data to pass to the screen
body: { type: string, description: Message body text }
header:
type: object
properties:
type: { type: string, enum: [text] }
text: { type: string }
footer: { type: string, description: Optional footer text }
draft: { type: boolean, description: "Set true to test an unpublished (DRAFT) flow" }
examples:
basic:
summary: Send a lead capture flow
value:
accountId: "507f1f77bcf86cd799439011"
to: "+1234567890"
flow_id: "1234567890"
flow_cta: "Get a Quote"
flow_action: "navigate"
flow_action_payload:
screen: "LEAD_FORM"
body: "Hi! Fill out this quick form to get a personalized quote."
responses:
'200':
description: Flow message sent
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
messageId: { type: string, description: WhatsApp message ID (WAMID) }
'400': { description: Validation error or missing phone number ID }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
/v1/whatsapp/flow-responses:
get:
x-resource-group: "messages"
operationId: listWhatsAppFlowResponses
tags: [WhatsApp Flows]
summary: List flow responses
description: |
List the responses customers submitted when completing a flow (parsed from the
nfm_reply messages received via webhook), newest first. Scope to a single flow
with `flowId`, which matches responses whose flow_token carries the `<flowId>:`
prefix that Zernio stamps on auto-generated tokens at send time. Responses sent
with a custom integrator-supplied flow_token are not attributed to a flow.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
- { name: flowId, in: query, required: false, schema: { type: string }, description: Scope to responses for this flow }
- { name: limit, in: query, required: false, schema: { type: integer, maximum: 200, default: 50 }, description: Max responses to return }
responses:
'200':
description: Flow responses
content:
application/json:
schema:
type: object
properties:
responses:
type: array
items:
type: object
properties:
id: { type: string, description: Message ID }
receivedAt: { type: string, format: date-time }
from: { type: [string, "null"], description: Sender wa_id / phone }
senderName: { type: [string, "null"] }
conversationId: { type: [string, "null"] }
flowToken: { type: [string, "null"] }
data: { type: object, description: Submitted field values (flow_token removed) }
raw: { type: [string, "null"], description: Raw response_json string }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
# ─── Contacts ─────────────────────────────────────────────────────
/v1/contacts:
get:
x-resource-group: "contacts"
operationId: listContacts
summary: List contacts
description: List and search contacts for a profile. Supports filtering by tags, platform, subscription status, and 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' }
# ─── Custom Fields ────────────────────────────────────────────────
/v1/custom-fields:
get:
x-resource-group: "accounts"
operationId: listCustomFields
summary: List custom field definitions
description: Returns all custom field definitions. Optionally filter by profile.
tags: [Custom Fields]
parameters:
- { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
responses:
'200':
description: List of custom field definitions
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
fields:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
slug: { type: string }
type: { type: string, enum: [text, number, date, boolean, select] }
options: { type: array, items: { type: string } }
createdAt: { type: string, format: date-time }
'400': { description: Invalid profileId format }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
x-resource-group: "accounts"
operationId: createCustomField
summary: Create custom field
description: Create a new custom field definition. Supported types are text, number, date, boolean, and select.
tags: [Custom Fields]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, name, type]
properties:
profileId: { type: string }
name: { type: string }
slug: { type: string, description: Auto-generated from name if not provided }
type: { type: string, enum: [text, number, date, boolean, select] }
options: { type: array, items: { type: string }, description: Required for select type }
responses:
'200':
description: Custom field created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
field:
type: object
properties:
id: { type: string }
name: { type: string }
slug: { type: string }
type: { type: string, enum: [text, number, date, boolean, select] }
options: { type: array, items: { type: string } }
createdAt: { type: string, format: date-time }
'400': { description: Invalid request body }
'401': { $ref: '#/components/responses/Unauthorized' }
'409': { description: Duplicate slug }
/v1/custom-fields/{fieldId}:
patch:
x-resource-group: "accounts"
operationId: updateCustomField
summary: Update custom field
description: Update a custom field definition. The field type cannot be changed after creation.
tags: [Custom Fields]
parameters:
- { name: fieldId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
name: { type: string }
options: { type: array, items: { type: string } }
responses:
'200':
description: Custom field updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
field:
type: object
properties:
id: { type: string }
name: { type: string }
slug: { type: string }
type: { type: string }
options: { type: array, items: { type: string } }
'400': { description: Invalid fieldId or request body }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
x-resource-group: "accounts"
operationId: deleteCustomField
summary: Delete custom field
description: Delete a custom field definition and remove its values from all contacts.
tags: [Custom Fields]
parameters:
- { name: fieldId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Custom field deleted }
'400': { description: Invalid fieldId format }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
# ─── Broadcasts ───────────────────────────────────────────────────
/v1/broadcasts:
get:
x-resource-group: "messages"
operationId: listBroadcasts
summary: List broadcasts
description: Returns broadcasts with delivery stats. Filter by status, platform, or profile.
tags: [Broadcasts]
parameters:
- { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
- { name: status, in: query, schema: { type: string, enum: [draft, scheduled, sending, completed, failed, cancelled] } }
- { name: platform, in: query, schema: { type: string } }
- { name: limit, in: query, schema: { type: integer, default: 50 } }
- { name: skip, in: query, schema: { type: integer, default: 0 } }
responses:
'200':
description: Broadcasts list
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
broadcasts:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
platform: { type: string }
accountId: { type: string }
accountName: { type: string, description: Display name of the sending account }
status: { type: string, enum: [draft, scheduled, sending, completed, failed, cancelled] }
messagePreview: { type: string, description: Template name or message text snippet }
scheduledAt: { type: string, format: date-time }
startedAt: { type: string, format: date-time }
completedAt: { type: string, format: date-time }
recipientCount: { type: integer }
sentCount: { type: integer }
deliveredCount: { type: integer }
readCount: { type: integer }
failedCount: { type: integer }
createdAt: { type: string, format: date-time }
pagination:
type: object
properties:
total: { type: integer }
limit: { type: integer }
skip: { type: integer }
hasMore: { type: boolean }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
x-resource-group: "messages"
operationId: createBroadcast
summary: Create broadcast draft
description: Create a broadcast in draft status. Add recipients and then send or schedule it.
tags: [Broadcasts]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, accountId, platform, name]
properties:
profileId: { type: string }
accountId: { type: string }
platform: { type: string, enum: [instagram, facebook, telegram, twitter, bluesky, reddit, whatsapp, 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' }
# ─── Sequences ────────────────────────────────────────────────────
/v1/workflows:
get:
x-resource-group: "accounts"
operationId: listWorkflows
summary: List workflows
description: Returns workflows with run stats. Filter by status or profile.
tags: [Workflows]
parameters:
- { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
- { name: status, in: query, schema: { type: string, enum: [draft, active, paused] } }
- { name: limit, in: query, schema: { type: integer, default: 50 } }
- { name: skip, in: query, schema: { type: integer, default: 0 } }
responses:
'200':
description: Workflows list
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
workflows:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
platform: { type: string }
accountId: { type: string }
accountName: { type: string }
status: { type: string, enum: [draft, active, paused] }
nodeCount: { type: integer }
totalStarted: { type: integer }
totalCompleted: { type: integer }
totalExited: { type: integer }
createdAt: { type: string, format: date-time }
pagination:
type: object
properties:
total: { type: integer }
limit: { type: integer }
skip: { type: integer }
hasMore: { type: boolean }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
x-resource-group: "accounts"
operationId: createWorkflow
summary: Create workflow
description: >
Create a branching conversation workflow (draft) from a node/edge graph. Created in `draft`
status; activate it to start matching inbound messages. The graph is validated structurally;
completeness (a trigger node + reachable entry) is required at activation.
tags: [Workflows]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, accountId, name]
properties:
profileId: { type: string }
accountId: { type: string }
platform: { type: string, enum: [whatsapp, instagram, facebook, telegram, twitter, bluesky, reddit], default: whatsapp }
name: { type: string }
description: { type: string }
nodes: { type: array, items: { $ref: '#/components/schemas/WorkflowNode' } }
edges: { type: array, items: { $ref: '#/components/schemas/WorkflowEdge' } }
entryNodeId: { type: string, description: The trigger node id; derived from the single trigger node if omitted }
responses:
'200':
description: Workflow created
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
workflow:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
platform: { type: string }
status: { type: string }
nodeCount: { type: integer }
entryNodeId: { type: string }
createdAt: { type: string, format: date-time }
'400': { description: "Invalid graph (duplicate node ids, edges referencing missing nodes, 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).
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.
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.
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
description: Provide either `to` (WhatsApp phone) or `conversationId`.
properties:
to: { type: string, description: Recipient phone (WhatsApp only) }
conversationId: { type: string, description: An existing conversation to run in (required for non-WhatsApp workflows) }
text: { type: string, description: "Simulated inbound text, seeded as the run's lastMessage variable" }
responses:
'200':
description: Run started
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
execution:
type: [object, "null"]
properties:
id: { type: string }
status: { type: string }
currentNodeId: { type: string }
waitingFor: { type: [object, "null"] }
variables: { type: object, additionalProperties: true }
conversationId: { type: string }
'400': { description: "Missing target, invalid graph, or `to` used on a non-WhatsApp workflow" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/workflows/{workflowId}/executions/{executionId}/events:
get:
x-resource-group: "messages"
operationId: listWorkflowExecutionEvents
summary: Get an execution's timeline
description: >
Returns the per-step run-log for a single workflow execution: trigger fired, each node
visited, edge handles taken, errors, and durations. Backed by Tinybird (90-day retention).
Used by the Runs UI drawer to render the timeline.
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
- { name: executionId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Timeline events for the execution
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
execution:
type: object
properties:
id: { type: string }
status: { type: string, enum: [running, waiting, completed, exited, failed] }
startedAt: { type: [string, "null"], format: date-time }
completedAt: { type: [string, "null"], format: date-time }
events:
type: array
description: Events in chronological order (oldest first).
items: { $ref: '#/components/schemas/WorkflowExecutionEvent' }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/workflows/{workflowId}/duplicate:
post:
x-resource-group: "accounts"
operationId: duplicateWorkflow
summary: Duplicate a workflow
description: >
Create an independent copy of a workflow's graph, name, description, and account binding.
The copy is created in `draft` status with fresh execution counters and a new id.
Execution history is NOT copied. Useful for branching off a known-good workflow before
making experimental edits.
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
responses:
'201':
description: Workflow duplicated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
workflow:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
status: { type: string, enum: [draft] }
platform: { type: string }
accountId: { type: string }
profileId: { type: string }
entryNodeId: { type: [string, "null"] }
nodeCount: { type: integer }
createdAt: { type: string, format: date-time }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/workflows/{workflowId}/versions:
get:
x-resource-group: "accounts"
operationId: listWorkflowVersions
summary: List a workflow's version history
description: >
Returns the snapshot history. A new version is recorded automatically before every PATCH
to `nodes` / `edges` / `entryNodeId`, and explicitly when a previous version is restored.
Lightweight list. Call `getWorkflowVersion` for the full snapshot graph.
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Versions list
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
versions:
type: array
description: Versions in reverse chronological order (newest first).
items:
type: object
properties:
version: { type: integer, description: Monotonically increasing version number }
name: { type: string }
description: { type: [string, "null"] }
createdBy: { type: [string, "null"], description: User id that authored this version }
createdByEmail: { type: [string, "null"], description: Denormalized email so the history UI can render without a join }
restoredFromVersion: { type: [integer, "null"], description: "When non-null, this snapshot was created by restoring that version" }
createdAt: { type: string, format: date-time }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/workflows/{workflowId}/versions/{version}:
get:
x-resource-group: "accounts"
operationId: getWorkflowVersion
summary: Get a specific workflow version
description: Returns the full snapshot for a single historical version, including the graph.
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
- { name: version, in: path, required: true, schema: { type: integer } }
responses:
'200':
description: Version snapshot
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
version:
type: object
properties:
version: { type: integer }
name: { type: string }
description: { type: [string, "null"] }
entryNodeId: { type: [string, "null"] }
nodes: { type: array, items: { $ref: '#/components/schemas/WorkflowNode' } }
edges: { type: array, items: { $ref: '#/components/schemas/WorkflowEdge' } }
platform: { type: string }
accountId: { type: string }
profileId: { type: string }
createdBy: { type: [string, "null"] }
createdByEmail: { type: [string, "null"] }
restoredFromVersion: { type: [integer, "null"] }
createdAt: { type: string, format: date-time }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/workflows/{workflowId}/versions/{version}/restore:
post:
x-resource-group: "accounts"
operationId: restoreWorkflowVersion
summary: Restore a workflow version
description: >
Replace the current graph with the named version's snapshot. Before the swap, the current
graph is itself snapshotted as a new version, so a restore is reversible. The workflow must
be in `draft` or `paused` status (same gate as a normal graph edit). The returned workflow
carries `restoredFromVersion` so the UI can surface which version was rolled back to.
tags: [Workflows]
parameters:
- { name: workflowId, in: path, required: true, schema: { type: string } }
- { name: version, in: path, required: true, schema: { type: integer } }
responses:
'200':
description: Workflow restored to the named version
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
workflow:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
status: { type: string }
entryNodeId: { type: [string, "null"] }
nodeCount: { type: integer }
updatedAt: { type: string, format: date-time }
restoredFromVersion: { type: integer }
'400': { description: "Workflow is not draft/paused, or the named version's graph is invalid for the current platform" }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/sequences:
get:
x-resource-group: "messages"
operationId: listSequences
summary: List sequences
description: Returns sequences with enrollment stats. Filter by status, platform, or profile.
tags: [Sequences]
parameters:
- { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
- { name: status, in: query, schema: { type: string, enum: [draft, active, paused] } }
- { name: limit, in: query, schema: { type: integer, default: 50 } }
- { name: skip, in: query, schema: { type: integer, default: 0 } }
responses:
'200':
description: Sequences list
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
sequences:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
description: { type: string }
platform: { type: string }
accountId: { type: string }
accountName: { type: string, description: Display name of the sending account }
messagePreview: { type: string, description: First step template name or message text snippet }
status: { type: string, enum: [draft, active, paused] }
stepsCount: { type: integer }
exitOnReply: { type: boolean }
exitOnUnsubscribe: { type: boolean }
totalEnrolled: { type: integer }
totalCompleted: { type: integer }
totalExited: { type: integer }
createdAt: { type: string, format: date-time }
pagination:
type: object
properties:
total: { type: integer }
limit: { type: integer }
skip: { type: integer }
hasMore: { type: boolean }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
post:
x-resource-group: "messages"
operationId: createSequence
summary: Create sequence
description: Create a multi-step messaging sequence. Each step has a delay and a message or WhatsApp template.
tags: [Sequences]
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, accountId, platform, name]
properties:
profileId: { type: string }
accountId: { type: string }
platform: { type: string, enum: [instagram, facebook, telegram, twitter, bluesky, reddit, whatsapp, 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' }
# ──────────────────────────────────────────────────────────────────────────
# COMMENT AUTOMATIONS (Comment-to-DM)
# ──────────────────────────────────────────────────────────────────────────
/v1/comment-automations:
get:
x-resource-group: "messages"
operationId: listCommentAutomations
tags: [Comment Automations]
summary: List comment-to-DM automations
description: List all comment-to-DM automations for a profile. Returns automations with their stats.
security:
- bearerAuth: []
parameters:
- { name: profileId, in: query, schema: { type: string }, description: Filter by profile. Omit to list across all profiles }
responses:
'200':
description: Automations list
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
automations:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
platform: { type: string, enum: [instagram, facebook] }
trigger: { type: string, enum: [comment, story_reply] }
accountId: { type: string }
platformPostId: { type: string }
postTitle: { type: string }
keywords: { type: array, items: { type: string } }
matchMode: { type: string, enum: [exact, contains, word], description: "How a keyword is compared with the comment. 'contains' (default) matches anywhere, even inside another word (keyword 'app' fires on 'happy'). 'word' matches the keyword only as a standalone word. 'exact' requires the whole comment to be exactly the keyword." }
excludeKeywords: { type: array, items: { type: string }, description: "Comments containing one of these never trigger the automation, even when a trigger keyword also matches. Compared using the same matchMode." }
typoTolerance: { type: boolean, description: "Only with matchMode=word: also fire on close misspellings of a keyword (one edit for 4-7 character keywords, two from 8 up). Keywords shorter than 4 characters are never fuzzy-matched." }
dmMessage: { type: string }
buttons:
type: array
items: { $ref: '#/components/schemas/DmButton' }
description: Inline DM buttons (up to 3). Omitted when none are set.
template:
$ref: '#/components/schemas/CommentAutomationTemplate'
commentReply: { type: string }
dmMessageVariations: { type: array, items: { type: string }, description: "Alternate DM texts rotated at random with dmMessage. Omitted when none." }
commentReplyVariations: { type: array, items: { type: string }, description: "Alternate public replies rotated at random with commentReply. Omitted when none." }
linkTracking: { type: boolean, description: "Whether link buttons in the DM are wrapped in a tracked redirect to count clicks." }
clickTag: { type: string, description: "Tag applied to a contact when they click a tracked link." }
dmDelaySeconds: { type: integer, description: "Seconds waited after the trigger before the DM is sent. Absent when the DM goes out immediately." }
commentReplyDelaySeconds: { type: integer, description: "Seconds waited before the public reply is posted. Absent when it follows the DM immediately." }
alsoMatchInDms: { type: boolean, description: "Whether these keywords also fire on a plain inbound DM." }
isActive: { type: boolean }
stats:
type: object
properties:
triggered: { type: integer }
dmsSent: { type: integer }
dmsFailed: { type: integer }
uniqueContacts: { type: integer }
trackedSends: { type: integer, description: "DMs sent with a trackable (wrapped) link. CTR denominator: divide clicks by this, not dmsSent. Lags dmsSent for campaigns that predate click tracking." }
linkClicks: { type: integer, description: "Total clicks on tracked links (bots/prefetch excluded)." }
uniqueClicks: { type: integer, description: "Distinct people who clicked a tracked link." }
delivered: { type: integer, description: "DMs confirmed delivered (Messenger; IG emits no delivery receipt)." }
read: { type: integer, description: "DMs confirmed read (IG messaging_seen / Messenger message_reads)." }
createdAt: { type: string, format: date-time }
'400': { description: Invalid request }
'401': { $ref: '#/components/responses/Unauthorized' }
'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.
When someone comments a matching keyword (or, with `trigger: story_reply`, replies
to your Instagram story with one), they automatically receive a DM.
Triggers (`trigger`):
* `comment` (default): fires on keyword comments on a post or reel.
* `story_reply`: fires when someone replies to your Instagram story with a keyword,
and answers them with a DM. Set `platformPostId` to a story media id to scope to
one story, or omit it to match replies to any story.
Targeting (comment trigger):
* Per-post: set `platformPostId` to scope to one specific post (only one active
per-post automation is allowed per post).
* Account-wide ("any post"): omit `platformPostId` (and `postId`). The automation
evaluates every comment on every post on the account. You can stack unlimited
account-wide automations, each with its own keyword set, and they all run
independently. Per-post automations take priority on their post.
Audience (`audience`, Instagram only): restrict the automation to followers or
non-followers, and/or to accounts above a follower count. Instagram only reveals the
follow relationship for people who have messaged the account, so `audience.whenUnknown`
decides what happens for everyone else - including `verify`, which sends a one-tap
confirmation DM (`followGate`) and then delivers the real DM automatically. People we
already know follow you skip the tap entirely.
Set `alsoMatchInDms: true` on a `comment` automation to also answer people who send
a keyword as a direct message instead of commenting it. One automation then covers
both doors, and each door is deduplicated separately (someone who already got the DM
from their comment still gets it if they later DM the keyword). Requires at least one
keyword.
Links in the DM's buttons can be click-tracked (`linkTracking`, on by default) and
clickers optionally tagged (`clickTag`) for segmentation. Stats returned include
delivered, read, and link clicks.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [profileId, accountId, name, dmMessage]
properties:
profileId: { type: string }
accountId: { type: string, description: Instagram or Facebook account ID }
trigger: { type: string, enum: [comment, story_reply], default: comment, description: "What fires the automation. 'comment' (keyword comment on a post) or 'story_reply' (keyword reply to an Instagram story). For 'story_reply', platformPostId is the story media id (omit for any story)." }
platformPostId: { type: string, description: "Platform media/post ID (or story media id when trigger=story_reply). Omit for an account-wide (any-post / any-story) automation." }
postId: { type: string, 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.
Pass `buttons: []` to clear all buttons. When `buttons` is non-empty, `dmMessage` (the new
one if you're changing it, otherwise the stored one) must be 640 characters or less.
security:
- bearerAuth: []
parameters:
- { name: automationId, in: path, required: true, schema: { type: string } }
requestBody:
content:
application/json:
schema:
type: object
properties:
name: { type: string }
trigger: { type: string, enum: [comment, story_reply], description: "What fires the automation. Changing it detaches the automation from its bound post or story (a post id and a story id are different objects), unless this same request sets a new binding. 'story_reply' is Instagram only." }
keywords: { type: array, items: { type: string } }
matchMode: { type: string, enum: [exact, contains, word], description: "How a keyword is compared with the comment. 'contains' (default) matches anywhere, even inside another word (keyword 'app' fires on 'happy'). 'word' matches the keyword only as a standalone word. 'exact' requires the whole comment to be exactly the keyword." }
excludeKeywords: { type: array, items: { type: string }, description: "Comments containing one of these never trigger the automation, even when a trigger keyword also matches. Compared using the same matchMode." }
typoTolerance: { type: boolean, description: "Only with matchMode=word: also fire on close misspellings of a keyword (one edit for 4-7 character keywords, two from 8 up). Keywords shorter than 4 characters are never fuzzy-matched." }
dmMessage: { type: string }
buttons:
type: array
maxItems: 3
items: { $ref: '#/components/schemas/DmButton' }
description: "Inline DM buttons (1-3). Pass [] to clear all buttons."
template:
oneOf:
- $ref: '#/components/schemas/CommentAutomationTemplate'
- type: 'null'
description: "Product card sent instead of the plain dmMessage bubble. Pass null to clear it and fall back to dmMessage. Mutually exclusive with buttons, including with the buttons already stored on the automation."
commentReply: { type: string }
dmMessageVariations:
type: array
maxItems: 5
items: { type: string }
description: "Alternate DM texts for random rotation (see create). Pass [] to clear."
commentReplyVariations:
type: array
maxItems: 5
items: { type: string }
description: "Alternate public replies for random rotation. Pass [] to clear."
linkTracking: { type: boolean, description: "Wrap link buttons in a tracked redirect to count clicks. Pass false to send links untouched." }
clickTag: { type: string, description: "Tag applied to a contact when they click a tracked link (requires linkTracking). Empty string clears it." }
alsoMatchInDms: { type: boolean, description: "Also fire these keywords on a plain inbound DM. Enabling it requires the automation to end up with at least one keyword (this request's keywords if you send them, otherwise the stored ones) and is rejected on story_reply automations." }
dmDelaySeconds: { type: integer, minimum: 0, maximum: 86400, description: "Seconds to wait after the trigger before sending the DM. Send 0 to clear the delay and reply immediately." }
commentReplyDelaySeconds: { type: integer, minimum: 0, maximum: 86400, description: "Seconds to wait before posting the public comment reply. Send 0 to clear it. The reply never goes out before the DM." }
audience: { $ref: '#/components/schemas/CommentAutomationAudience' }
followGate: { $ref: '#/components/schemas/CommentAutomationFollowGate' }
isActive: { type: boolean }
responses:
'200':
description: Automation updated
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
automation:
type: object
properties:
id: { type: string }
name: { type: string }
keywords: { type: array, items: { type: string } }
matchMode: { type: string, enum: [exact, contains, word], description: "How a keyword is compared with the comment. 'contains' (default) matches anywhere, even inside another word (keyword 'app' fires on 'happy'). 'word' matches the keyword only as a standalone word. 'exact' requires the whole comment to be exactly the keyword." }
excludeKeywords: { type: array, items: { type: string }, description: "Comments containing one of these never trigger the automation, even when a trigger keyword also matches. Compared using the same matchMode." }
typoTolerance: { type: boolean, description: "Only with matchMode=word: also fire on close misspellings of a keyword (one edit for 4-7 character keywords, two from 8 up). Keywords shorter than 4 characters are never fuzzy-matched." }
dmMessage: { type: string }
buttons:
type: array
items: { $ref: '#/components/schemas/DmButton' }
description: Inline DM buttons (up to 3). Omitted when none are set.
template:
$ref: '#/components/schemas/CommentAutomationTemplate'
commentReply: { type: string }
dmMessageVariations: { type: array, items: { type: string }, description: "Alternate DM texts rotated at random with dmMessage. Omitted when none." }
commentReplyVariations: { type: array, items: { type: string }, description: "Alternate public replies rotated at random with commentReply. Omitted when none." }
audience: { $ref: '#/components/schemas/CommentAutomationAudience' }
followGate: { $ref: '#/components/schemas/CommentAutomationFollowGate' }
alsoMatchInDms: { type: boolean, description: "Whether these keywords also fire on a plain inbound DM." }
isActive: { type: boolean }
updatedAt: { type: string, format: date-time }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
delete:
x-resource-group: "messages"
operationId: deleteCommentAutomation
tags: [Comment Automations]
summary: Delete automation
description: Permanently delete an automation and all its trigger logs.
security:
- bearerAuth: []
parameters:
- { name: automationId, in: path, required: true, schema: { type: string } }
responses:
'200': { description: Automation deleted }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/NotFound' }
/v1/comment-automations/{automationId}/logs:
get:
x-resource-group: "messages"
operationId: listCommentAutomationLogs
tags: [Comment Automations]
summary: List automation logs
description: Paginated list of every comment that triggered this automation, with send status and commenter info.
security:
- bearerAuth: []
parameters:
- { name: automationId, in: path, required: true, schema: { type: string } }
- { name: status, in: query, schema: { type: string, enum: [pending, sent, failed, skipped, gated] }, description: Filter by result status }
- { name: limit, in: query, schema: { type: integer, default: 50, minimum: 1, maximum: 200 } }
- { name: skip, in: query, schema: { type: integer, default: 0, minimum: 0 } }
responses:
'200':
description: Trigger logs with pagination
content:
application/json:
schema:
type: object
properties:
success: { type: boolean }
logs:
type: array
items:
type: object
properties:
id: { type: string }
commentId: { type: string }
commenterId: { type: string }
commenterName: { type: string }
commentText: { type: string }
source: { type: string, enum: [comment, story_reply, dm], description: "Which door triggered this send. Absent on rows written before this field existed (all of those are comment-triggered)." }
status: { type: string, enum: [pending, sent, failed, skipped, gated], description: "DM outcome. 'pending' = the automation has a dmDelaySeconds and the response is queued but not sent yet. 'gated' = the follow-gate confirmation DM went out and we are waiting for the tap; it flips to 'sent' or 'skipped' when they tap." }
audienceOutcome: { type: string, enum: [passed, blocked, gate_sent, gate_passed, gate_failed], description: "How the audience rule resolved. Absent on automations without one." }
commenterIsFollower: { type: boolean, description: "Follow relationship at decision time. Absent when Instagram would not tell us (the commenter never messaged the account)." }
commenterFollowerCount: { type: integer }
error: { type: string, description: DM error message if status is failed }
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' }
# ── Ads ──────────────────────────────────────────────────────────────────
/v1/ads:
get:
x-resource-group: "ads"
operationId: listAds
tags: ["Ad Campaigns"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: List ads
description: |
Returns a paginated list of ads with metrics computed over an optional date range.
Use source=all to include externally-synced ads from platform ad managers.
If no date range is provided, defaults to the last 90 days. Date range is capped at 730 days max.
To find the Zernio ad behind a comment you see in Meta Business Manager, filter by
platformAdId (the Meta ad ID), effectiveObjectStoryId (Facebook), or
effectiveInstagramMediaId (Instagram). Those are the post/media the ad's engagement
lives on, and are also returned on each ad's `creative` object. Then call
GET /v1/ads/{adId}/comments with the returned ad id.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/PageParam'
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 50 } }
- { name: source, in: query, schema: { type: string, enum: [zernio, all], default: all }, description: "all (default) = Zernio-created + platform-discovered ads. zernio = restrict to Zernio-created only." }
- { name: status, in: query, schema: { $ref: '#/components/schemas/AdStatus' } }
- { name: platform, in: query, schema: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter, openai] } }
- { name: accountId, in: query, schema: { type: string }, description: 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
status and spend metrics, the raw material for wasted-spend analysis and
negative-keyword lists. Reads Google's `search_term_view`, cached for
the quota window; defaults to the last 30 days. Rows are ordered by
cost, descending. 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." }
- { 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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),
read live from Google's `local_services_lead` resource, newest first.
No persistence: Google is the source of truth and lead/credit statuses
keep changing server-side. Google never returns healthcare-category
leads, and `WIPED_OUT` leads arrive with contact erased (`contact` is
null). 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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' }
'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,
recording URL) and messages (text, attachment URLs), oldest first. Read
live from `local_services_lead_conversation`, always scoped to a single
lead. Call-recording URLs require read access on the Google Ads account.
Draws 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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' }
'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
connected Google Ads accounts, one row per ad-group keyword. Refreshed about
once a week per Google Ads customer (the keyword sweep rides the ads discovery
pass on a slower slot, to stay inside Google's shared daily API quota), so
keywords added on Google can take several days to appear. A customer synced
for the first time is populated on the next discovery pass rather than
waiting for its weekly slot, and connecting an account or triggering a
manual sync refreshes it immediately.
Campaign-level negative keywords are not included; only ad-group-level
criteria are.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/PageParam'
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 50 } }
- { name: accountId, in: query, schema: { type: string }, description: 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,
without touching the keywords already there (unlike the whole-set diff on
`PUT /v1/ads/{adId}`, `keywords`/`negativeKeywords` in `platformSpecificData`,
which replaces the set). Set `negative: true` to add ad-group-level negatives
instead of positive keywords.
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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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).
Negative keywords have no status on Google and cannot be paused or enabled.
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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
/v1/ads/campaigns:
get:
x-resource-group: "ads"
operationId: listAdCampaigns
tags: ["Ad Campaigns"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: List campaigns
description: |
Returns campaigns as virtual aggregations over ad documents grouped by platform campaign ID.
Metrics (spend, impressions, clicks, etc.) are summed across all ads in each campaign.
Campaign status is derived from child ad statuses (active > pending_review > paused > error > completed > cancelled > rejected).
Google campaign budgets include amountMicros, explicitlyShared, resourceName and
deliveryMethod after the next successful sync. This endpoint does not fetch Google live.
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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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.
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
`accountId`. Ad sets join it later via `existingCampaignId` on the create endpoints.
Platform notes: on Meta a budget here is campaign-level (CBO) by definition; omit it
for ABO (each ad set carries its own budget), and `specialAdCategories` is Meta-only
(400 elsewhere); `bidStrategy` is Meta and Google (400 elsewhere), and Google also
accepts `portfolioBidStrategyId` instead. Google, X and OpenAI require a budget
(422 without one; OpenAI accepts only `budgetType: lifetime`, Google only
`budgetType: daily`). LinkedIn creates the
campaign GROUP (our campaign level) and rejects a budget, which lives on the
campaign (ad set) level there; it comes back `status: DRAFT`. TikTok campaigns are
created without a status and report `ENABLE`. Created `PAUSED` unless
`status: ACTIVE` where the platform supports it.
**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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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' }
'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.
Makes one platform API call, not one per ad.
The switch is always written, whatever delivery status the ads underneath report: an ad still in review
does not block resuming its campaign. The echoed `status` is the confirmation that it landed.
`updated` / `skipped` describe only the ads whose own stored status CHANGED alongside it, so
`updated: 0` is a normal successful response, not a no-op. Ads are skipped when they are in a terminal
status (rejected, completed, cancelled), already in the target state, or switched on but not yet
delivering. The last group keeps its `pending_review` / `error` status until the platform reports what
it became. `skippedReasons` names which case applies.
On Meta this flips the campaign only. An ad set paused in its own right stays paused, so pair this with
PUT /v1/ads/ad-sets/{adSetId}/status when you also need the ad set switched back on.
Google keeps an independent on/off switch at campaign, ad group and ad level and the most restrictive
one wins, so `active` switches the campaign on TOGETHER with the ad groups and ads Zernio tracks under
it, in one mutate. Without that the campaign reads ENABLED while a paused ad group or ad keeps it from
serving. `paused` writes the campaign alone, which already stops delivery and leaves each ad's own
switch as you set it.
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
criterion ids Google minted for them.
An EMPTY `schedule` is meaningful and is not a failed lookup: Google has no
"all day" criterion, so a campaign with no ad schedule serves around the clock.
`servesAroundTheClock` states that explicitly.
Set `includePerformance=true` to also get delivery split by day of week and by hour,
which is the evidence for deciding what the schedule should be. It is one extra
Google call segmented by both dimensions at once, so the two views always agree.
Google Ads only. 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: 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
REPLACE, not a merge: windows you leave out stop serving.
Send `schedule: []` to clear dayparting, which returns the campaign to serving around
the clock.
Google rules enforced here, so you get a named field instead of a criterion error:
at most 6 windows per day, a window must end after it starts, windows on the same day
may not overlap, `endHour` 24 is midnight and cannot carry minutes, and minutes are
quarter-hours only (0, 15, 30, 45). `bidModifier` is 0.1-10.0; Google's 0 means
"off" for devices only, so a window is switched off by leaving it out.
Windows are half-open (Google is exclusive of the end minute), so 09:00-12:00 and
12:00-17:00 on the same day are adjacent and both valid.
Google cannot edit an ad schedule in place (every AdScheduleInfo field is prohibited on
update), so this removes the live criteria and creates the new ones in a single atomic
mutate. The response is read back from Google and carries the new criterion ids.
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, 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
pre-filling the bid strategy block before a PUT to /v1/ads/campaigns/{campaignId}.
Google Ads only; `platform` is required and rejected when it is anything else, since
a `campaignId` is not globally unique. The response carries `cachedAt` and `stale`,
set when a quota-exhausted call falls back to the last-good copy instead of a live
read.
Maps Google's bidding strategy onto the same triplet PUT accepts: `LOWEST_COST_WITHOUT_CAP`
(Maximize Conversions, no target), `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). A campaign on a portfolio strategy returns `portfolio` (id + name) and
`bidSpec.portfolioBidStrategyId` instead of the triplet. Anything else (Manual CPC, Target
Impression Share, ...) returns `bidSpec: null`; show `biddingStrategyType` as-is.
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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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
campaign id no longer has to page `GET /v1/ads/campaigns` to find it. The default
projection covers name, status, objective, buying type, bid strategy, budgets,
spend cap, schedule and `issues_info`. `fields` is a raw-passthrough override;
unknown fields return Meta's 400 verbatim. A campaign the resolved connection
cannot 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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' }
'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`,
`portfolioBidStrategyId`, `name` or `platformSpecificData`. An unsupported
field is always an error, never a silent drop.
| Body field | Meta | Google | Others |
|---|---|---|---|
| `bidStrategy` | Yes | Yes | 501 |
| `bidAmount`, `roasAverageFloor` | 400 (ad-set level) | Yes | 400 |
| `portfolioBidStrategyId` | 400 | Yes | 400 |
| `budget` (CBO; ABO returns 409) | Yes | Daily only | 501 |
| `name` | Yes | 501 | 501 |
| `platformSpecificData.spendCap` | Yes | 400 | 400 |
| `accountId` (empty campaigns) | Yes | - | - |
Meta budget edits check the live campaign budget, so an older local ABO stamp
cannot block a CBO campaign. A successful edit repairs local ad budget fields.
A live ABO campaign still returns 409 with the ad-set budget endpoint.
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
(exclusive with `bidStrategy`). Setting the standard triplet on a campaign that
is currently on a PORTFOLIO strategy is rejected: detach it in Google Ads
first, since it is shared across campaigns.
Google budget updates read the current budget before mutation. Shared budgets return
409 unless allowSharedBudgetUpdate=true is explicitly supplied, because the change
affects every campaign using that budget. Unknown sharing state also returns 409.
`accountId` forwards the update straight to Meta for a campaign with zero ads,
which would otherwise 404; the response then carries `updated: 0`.
security:
- bearerAuth: []
parameters:
- { name: campaignId, in: path, required: true, schema: { type: string }, description: Platform campaign ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform: { type: string, enum: [facebook, instagram, google], description: "Required: platform campaign IDs are not globally unique." }
accountId: { type: string, description: "**Meta only.** Zernio SocialAccount id owning the ad account. Needed only for an EMPTY campaign (zero ads); ignored otherwise." }
bidStrategy:
allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
description: "**Meta + Google.** On Meta, the campaign default that ad sets inherit unless they override it. On Google, the campaign's own bidding strategy. 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
and ads. Locally, all Ad documents for this campaign are marked
`status: cancelled`.
**Empty campaigns.** A campaign with zero ads has no local Ad documents
to resolve, so it is invisible to `/v1/ads/tree` and this endpoint would
404. That state is produced by the two-step create flow (campaign, then
ads via `existingCampaignId`) whenever Meta rejects the ad step. To
delete such a shell, send `accountId` in the body: we skip the local
lookup entirely and forward the delete to Meta. `accountId` is ignored
when the campaign does have ads.
security:
- bearerAuth: []
parameters:
- { name: campaignId, in: path, required: true, schema: { type: string }, description: Platform campaign ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform: { type: string, enum: [facebook, instagram, 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'400': { $ref: '#/components/responses/BadRequest' }
'200':
description: Campaign deleted
content:
application/json:
schema:
type: object
properties:
deleted: { type: boolean }
adCount: { type: integer, description: Number of local Ad docs marked cancelled }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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`),
distinct from the ad-group-level negatives under `GET /v1/ads/keywords`. Cached
for the quota window (not synced to Postgres), and gated by the shared Google
Ads operations budget like every other on-demand Google surface. The response
carries `cachedAt` and `stale`, set when a quota-exhausted call falls back to
the last-good copy instead of a live read.
The platform is always discovered from the campaign itself; a non-Google
campaign returns 501 rather than 404, whether or not `platform` was passed.
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
list is diffed against what Google already has, and the difference is applied
as one `create`/`remove` mutate. Send an empty array to clear every campaign
negative.
The platform is always discovered from the campaign itself; a non-Google
campaign returns 501 rather than 404, whether or not `platform` was sent.
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
concurrently and the response contains a per-campaign result so a
single bad row does not fail the whole batch.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [status, campaigns]
properties:
status: { type: string, enum: [active, paused] }
campaigns:
type: array
maxItems: 50
items:
type: object
required: [platformCampaignId, platform]
properties:
platformCampaignId: { type: string }
platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter, openai] }
responses:
'200':
description: Per-campaign results
content:
application/json:
schema:
type: object
properties:
status: { type: string, enum: [active, paused] }
totals:
type: object
properties:
updated: { type: integer }
skipped: { type: integer }
failed: { type: integer }
results:
type: array
items:
type: object
properties:
platformCampaignId: { type: string }
platform: { type: string }
updated: { type: integer }
skipped: { type: integer }
error: { type: string }
'400': { description: Invalid input }
'401': { $ref: '#/components/responses/Unauthorized' }
'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
targeting by default (`deepCopy: true`). The copy is created paused
so callers can review before launching.
Per-platform implementation:
- **Meta** uses the native `POST /{campaign-id}/copies` endpoint.
- **TikTok** has no native copy primitive; Zernio walks the source
graph (`/v2/campaign/get/`, `/v2/adgroup/get/`, `/v2/ad/get/`) and
recreates each entity via the corresponding `/create/` endpoints,
carrying over budget / targeting / bid_type / bid_price /
deep_bid_type / creative fields. Spark Ad linkage (`tiktok_item_id`)
is preserved.
- **LinkedIn** has no native copy primitive; Zernio walks the source
CampaignGroup → Campaigns → Creatives and recreates each entity,
carrying over `type` / `costType` / `unitCost` /
`optimizationTargetType` / `creativeSelection` / `objectiveType` /
`format` / `dailyBudget` / `totalBudget` / `targetingCriteria` /
`runSchedule` and every Creative's `content` object verbatim.
`statusOption: INHERITED_FROM_SOURCE` is evaluated **per entity**:
any Group / Campaign / Creative whose source is `ACTIVE` gets its
clone activated too. Duplicating an ACTIVE campaign with
`INHERITED_FROM_SOURCE` starts a second front of spend the moment
the clone activates. The safe default is `PAUSED`.
The new hierarchy is asynchronous to materialize in our DB, and we
trigger sync discovery automatically. Set `syncAfter: false` to
skip and poll `/v1/ads/tree` on your own cadence.
Other platforms return 501 Not Implemented.
security:
- bearerAuth: []
parameters:
- name: Idempotency-Key
in: header
required: false
schema: { type: string, maxLength: 255 }
description: >-
Optional client-generated unique key (e.g. a UUID) that makes
retries safe. Same key + same body replays the original response;
same key + different body → 422; key still processing → 409. Only
2xx responses are stored, so a request that failed with a 4xx can
be retried with a corrected body under the SAME key.
- { name: campaignId, in: path, required: true, schema: { type: string }, description: Source platform campaign ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform: { type: string, enum: [facebook, instagram, tiktok, linkedin] }
deepCopy: { type: boolean, default: true, description: Copy child ad sets + ads + creatives + targeting }
statusOption:
type: string
enum: [ACTIVE, PAUSED, INHERITED_FROM_SOURCE]
default: PAUSED
description: >
ACTIVE = launch the clone immediately (spends the moment
LinkedIn approves it). PAUSED = clone stays DRAFT, safe
default. INHERITED_FROM_SOURCE = mirror each entity's
source status per-entity. Duplicating an ACTIVE
campaign this way starts a second front of spend.
startTime: { type: string, format: date-time, description: Reschedule the copied hierarchy's start time }
endTime: { type: string, format: date-time }
renameStrategy:
type: string
enum: [DEEP_RENAME, ONLY_TOP_LEVEL_RENAME, NO_RENAME]
renamePrefix: { type: string }
renameSuffix: { type: string }
syncAfter: { type: boolean, default: true, description: Trigger ads discovery on the owning account after the copy succeeds }
responses:
'200':
description: Campaign duplicated
content:
application/json:
schema:
type: object
properties:
copiedCampaignId: { type: string, description: Platform ID of the new campaign }
discovery: { type: string, enum: [triggered, skipped, failed] }
raw:
type: object
description: Platform-native response from the copy endpoint (Meta includes ad_object_ids for child copies)
'400': { description: Invalid input }
'401': { $ref: '#/components/responses/Unauthorized' }
'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
set at creation to stay editable afterwards; this reads the campaign state
so an integrator can build an editor around it. Cached for the quota window
(10 minutes fresh, up to 7 days last-good), not always a live read. Google
only; every other platform returns 501.
`devices` lists the device criteria the campaign carries, which depends on
its channel: Search campaigns have MOBILE, DESKTOP and TABLET, Display
campaigns also have CONNECTED_TV. `bidModifier` is Google's bid adjustment
for that device, `null` when it has none, and `0` when the device is
switched off; `included` is false for exactly that case.
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
creation must stay editable afterwards. Send at least one of `devices`,
`locations`, `languages`; each provided field REPLACES that field's
existing criteria on the campaign (a full set, not a delta). Fields left
out of the body are untouched. Google only; every other platform returns
501.
`devices` is the full set of device bid modifiers: a supported device you
leave out is switched off with a bid modifier of 0, since Google cannot
remove a device criterion. A device the campaign's channel does not carry,
and a set that switches every device off, both return 422.
`locations` accepts the same shapes as campaign creation: a bare array of
ISO country codes, or an object with `countries`/`regions`/`cities`/`zips`/`metros`
key lists (`key` from GET /v1/ads/targeting/search?dimension=geo). Negative
(excluded) locations are left untouched by this endpoint. An empty location list
returns 400 instead of removing every criterion: a Google campaign with no location
criteria targets every country, so omit `locations` to leave targeting alone.
The removes and the creates go out in ONE Google `googleAds:mutate`, so a failed
edit leaves the campaign's previous set intact rather than a half-applied one.
`languages` is an array of Google's language codes (ISO 639-1, plus variants
such as `zh_CN`); an unknown code returns 400.
The response includes the refreshed `devices`/`locations`/`languages` state
read back from Google after the edit, and invalidates the cached copy
`GET` on this campaign would otherwise keep serving.
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
filtered by platform and campaignId. Reads the `ad_sets` table
directly, independent of the `ads` rollup GET /v1/ads/tree uses, so a
newly created standalone ad group with no ad yet (POST /v1/ads/ad-sets,
Google only) is visible here even though it is invisible in the tree
until an ad joins it via `adSetId` on POST /v1/ads/create. Returns at most 500
rows, 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,
under an existing campaign. Ads join it later via `adSetId`
on POST /v1/ads/create. Google only; every other platform returns 501.
Created `PAUSED` unless `status: ACTIVE`. The new ad group has no ad
yet, so it will not appear in GET /v1/ads/tree (built purely from `ads`
rows) until one is added; use GET /v1/ads/ad-sets to see it in the
meantime.
**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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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`),
via Meta's native `POST /{adset-id}/copies`. The copy is created paused so callers can
review before launching. `campaignId` retargets the copy into another campaign; omitted
= the source's own campaign. The new hierarchy materializes asynchronously, and sync
discovery is triggered automatically (`syncAfter: false` to skip).
security:
- bearerAuth: []
parameters:
- name: Idempotency-Key
in: header
required: false
schema: { type: string, maxLength: 255 }
description: >-
Optional client-generated unique key (e.g. a UUID) that makes
retries safe. Same key + same body replays the original response;
same key + different body → 422; key still processing → 409. Only
2xx responses are stored, so a request that failed with a 4xx can
be retried with a corrected body under the SAME key.
- { name: adSetId, in: path, required: true, schema: { type: string }, description: Source platform ad set ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform: { type: string, enum: [facebook, instagram] }
campaignId: { type: string, description: Destination platform campaign id (defaults to the source's campaign) }
deepCopy: { type: boolean, default: true, description: Copy child ads + creatives }
statusOption: { type: string, enum: [ACTIVE, PAUSED, INHERITED_FROM_SOURCE], default: PAUSED }
startTime: { type: string, format: date-time, description: Reschedule the copy's start time }
endTime: { type: string, format: date-time }
renameStrategy: { type: string, enum: [DEEP_RENAME, ONLY_TOP_LEVEL_RENAME, NO_RENAME] }
renamePrefix: { type: string }
renameSuffix: { type: string }
syncAfter: { type: boolean, default: true }
responses:
'200':
description: Ad set duplicated
content:
application/json:
schema:
type: object
properties:
copiedAdSetId: { type: string, description: Platform ID of the new ad set }
discovery: { type: string, enum: [triggered, skipped, failed] }
raw: { type: object, description: Meta's native copy response (includes ad_object_ids for child copies) }
'400': { description: Invalid input }
'401': { $ref: '#/components/responses/Unauthorized' }
'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
paused. `adSetId` retargets the copy into another ad set; omitted = the source's own ad
set. Accepts the Zernio ad id or the platform ad id. Sync discovery is triggered
automatically (`syncAfter: false` to skip). Creative settings returned by Meta,
including explicit promotion metadata and creativeFeatures, are preserved when the
native 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
`learning_stage_info` (learning-phase status: LEARNING / SUCCESS / FAIL / WAIVING; Meta
omits its `status` key on paused ad sets), delivery settings, budgets, schedule and
targeting. `fields` is a raw-passthrough override; unknown fields return Meta's 400
verbatim.
security:
- bearerAuth: []
parameters:
- { name: adSetId, in: path, required: true, schema: { type: string }, description: "Meta ad set id (platformAdSetId)." }
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: fields, in: query, schema: { type: string, 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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' }
'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
pause/resume, bid-strategy edits, Meta value-rule-set attach/detach, and
Meta-only post-launch delivery settings via `platformSpecificData`. At
least one updatable field is required.
Value rule sets (Meta only, see `/v1/ads/value-rule-sets`):
- ATTACH or REPLACE: send `valueRuleSetId`. Attachment is driven by the id's
presence, so `valueRulesApplied: true` is optional. Sending a different id
replaces the previous association; there is no separate replace call.
- DETACH: send `valueRulesApplied: false` and OMIT `valueRuleSetId`.
- Sending `valueRulesApplied: false` TOGETHER with `valueRuleSetId` returns 400
`mutually_exclusive_fields`. This is deliberate: Meta attaches the rule set
whenever `value_rule_set_id` is present, even with `value_rules_applied` false,
so echoing stored state while asking to detach would silently keep the bid
adjustments live.
- Eligibility: only ad sets on `LOWEST_COST_WITHOUT_CAP` or `COST_CAP`. Meta
rejects the rest server-side.
- Read back with `GET /v1/ads/ad-sets/{adSetId}?fields=value_rule_set_id`. Meta
does not document `value_rules_applied` as a readable ad-set field, so the
boolean cannot be read back.
Bid strategy compatibility (per Meta's spec):
- `LOWEST_COST_WITHOUT_CAP`: no `bidAmount`, no `roasAverageFloor`.
- `LOWEST_COST_WITH_BID_CAP` / `COST_CAP`: `bidAmount` REQUIRED (whole currency units).
- `LOWEST_COST_WITH_MIN_ROAS`: `roasAverageFloor` REQUIRED (decimal multiplier, e.g. 2.0 = 2.0x ROAS).
- Meta only: send `bidAmount` WITHOUT `bidStrategy` to change the cap amount on an ad set
under a COST_CAP / LOWEST_COST_WITH_BID_CAP parent campaign, leaving the strategy itself
(inherited from the campaign) untouched. `roasAverageFloor` without `bidStrategy` is
rejected (it has no meaning outside LOWEST_COST_WITH_MIN_ROAS).
Delivery settings are validated by Meta against the campaign objective;
incompatible combinations (e.g. a billingEvent the optimization goal
doesn't allow) surface as 400s from Meta.
When updating `budget` on an ABO campaign: if the parent campaign is
CBO, the response is 409 with code BUDGET_LEVEL_MISMATCH. Route to
PUT /v1/ads/campaigns/{campaignId} instead.
security:
- bearerAuth: []
parameters:
- { name: adSetId, in: path, required: true, schema: { type: string }, description: Platform ad set ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [platform]
properties:
platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter, openai] }
budget:
type: object
description: Omit if not updating budget
properties:
amount: { type: number }
type: { type: string, enum: [daily, lifetime] }
status: { type: string, enum: [active, paused], description: "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.
Supported on Meta (facebook, instagram), TikTok, and OpenAI. On TikTok the
Meta-style enum is mapped to bid_type / bid_price / deep_bid_type
automatically. On OpenAI, LOWEST_COST_WITH_BID_CAP and COST_CAP both map to
the ad group's `bidding_config.max_bid_micros` (one knob covers both);
LOWEST_COST_WITH_MIN_ROAS is rejected with 422 (OpenAI has no ROAS-based
bidding). Other platforms (linkedin, pinterest, google, twitter) return 501
Not Implemented when bidStrategy is set.
bidAmount:
type: number
description: |
Bid cap in WHOLE currency units (USD: 5 = $5.00; JPY: 100 = ¥100). Required when
bidStrategy is LOWEST_COST_WITH_BID_CAP or COST_CAP. Internally converted to Meta's
smallest-denomination integer, or (on OpenAI) to micros (× 1,000,000). Meta only:
may be sent alone, WITHOUT bidStrategy, to update the cap amount on an ad set whose
parent campaign is COST_CAP or LOWEST_COST_WITH_BID_CAP (the strategy is inherited
from the campaign and is left untouched).
roasAverageFloor:
type: number
description: |
Minimum ROAS as a decimal multiplier (2.0 = 2.0x). Required when bidStrategy is
LOWEST_COST_WITH_MIN_ROAS. Sent to Meta as `bid_constraints.roas_average_floor` × 10000.
Not supported on OpenAI (422).
valueRuleSetId:
type: string
pattern: '^\d+$'
description: |
Meta only (other platforms return 501). Value rule set to attach to this ad
set, from `/v1/ads/value-rule-sets`. Sending a different id replaces the
current association. To DETACH, send `valueRulesApplied: false` and omit
this field.
valueRulesApplied:
type: boolean
description: |
Meta only (other platforms return 501). `false` DETACHES the ad set's value
rule set and must be sent WITHOUT `valueRuleSetId`; the combination returns
400. `true` is optional when attaching, since attachment is driven by
`valueRuleSetId`, and requires it to be present.
platformSpecificData:
type: object
description: |
Platform-specific post-launch delivery settings. The platform is implied by the
`platform` body param. Meta only; other platforms return 400. Unknown keys are rejected.
properties:
optimizationGoal: { type: string, description: "Meta ad-set optimization_goal (e.g. OFFSITE_CONVERSIONS, LANDING_PAGE_VIEWS)." }
billingEvent: { type: string, description: "Meta ad-set billing_event (e.g. IMPRESSIONS, LINK_CLICKS, THRUPLAY)." }
startDate: { type: string, description: "Ad set start_time (ISO 8601)." }
endDate: { type: string, description: "Ad set end_time (ISO 8601)." }
dailyMinSpendTarget:
type: number
description: |
Meta `daily_min_spend_target`: the least this ad set should spend per day, in whole
currency units of the ad account. It reserves a share of a CAMPAIGN budget for one ad
set, so it requires a campaign using Advantage campaign budget (CBO). On an ad set
that owns its budget (ABO) this returns 409. Move the budget to the campaign with
`PUT /v1/ads/campaigns/{campaignId}` first. Meta treats it as a target, not a
guarantee, and rejects the combined minimum of a campaign's ad sets going over the
campaign budget.
Mutually exclusive with `lifetimeMinSpendTarget` (400): the flavour must match the
campaign budget type, a daily budget takes a daily target.
Read it back with `GET /v1/ads/ad-sets/{adSetId}?fields=daily_min_spend_target`.
lifetimeMinSpendTarget:
type: number
description: |
Meta `lifetime_min_spend_target`: the lifetime-budget flavour of
`dailyMinSpendTarget`, in whole currency units. Send this one when the campaign
budget is a lifetime budget. Same rules and same rejections.
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
campaign). Locally, every Ad document under the ad set is marked
`status: cancelled`.
Delete is soft on platforms that have no hard delete: LinkedIn moves the
campaign to `PENDING_DELETION`, Pinterest archives the ad group, and X
soft-flags the line item. Google removes the ad group. All remain readable
for reporting.
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
over PUT /v1/ads/ad-sets/{adSetId} for callers that only want the
status toggle and prefer a symmetric URL to
/v1/ads/campaigns/{campaignId}/status.
On Meta and LinkedIn this writes the ad set's own on/off switch
(Meta: `configured_status`), whatever delivery status its ads report:
an ad still in review does not block resuming its ad set. The echoed
`status` is the confirmation that it landed. Where the platform has no
ad-set switch (TikTok and others) the toggle is emulated by flipping the
child ads; a call with no actionable ad then writes nothing and returns a
`message` with no `status`.
`updated` / `skipped` describe only the ads whose own stored status
CHANGED alongside the switch, so `updated: 0` is a normal successful
response. See `skippedReasons` for which of the three cases applies
(terminal, already in the target state, or switched on but not yet
delivering).
A campaign created paused needs its campaign resumed as well: pair this
with PUT /v1/ads/campaigns/{campaignId}/status.
security:
- bearerAuth: []
parameters:
- { name: adSetId, in: path, required: true, schema: { type: string }, description: Platform ad set ID }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [status, platform]
properties:
status: { type: string, enum: [active, paused] }
platform: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter, openai] }
responses:
'200':
description: Ad set status updated
content:
application/json:
schema:
type: object
properties:
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.
Uses a two-stage aggregation: ads are grouped into ad sets, then ad sets into campaigns.
Metrics are computed over an optional date range, then rolled up from ad level to ad set
and campaign levels. Pagination is at the campaign level. Ads without a campaign or ad set
ID are grouped into synthetic "Ungrouped" buckets.
If no date range is provided, defaults to the last 90 days. Date range is capped at 730 days max.
Pass `timeIncrement=1` to also get a daily breakdown: each node gains a `daily[]` array of
per-day metrics (same fields as the aggregated `metrics`) in the same call. Use `dailyLevel`
(`campaign` default, or `adset` / `ad`) to choose which levels carry the series. This replaces
calling the tree once per day for per-campaign daily trends.
**Deleted objects stay in the tree.** Deleting an ad or a campaign is a soft delete: the Ad
documents move to `status: cancelled` and are kept indefinitely, so their historical spend
still counts toward the metrics of any date range they fall in. There is no pruning job and
no retention window. Filter on `status` if your view should hide them, but do that after
reading the totals, not before.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/PageParam'
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 20 }, description: Campaigns per page }
- { name: source, in: query, schema: { type: string, enum: [zernio, all], default: all }, description: "`all` (default) returns both Zernio-created ads and those discovered from the platform's ad manager. Matches the web UI's default view. Pass `zernio` to restrict to isExternal=false only. Status is NOT filtered by default; use the `status` param for that." }
- { name: platform, in: query, schema: { type: string, enum: [facebook, instagram, tiktok, linkedin, pinterest, google, twitter, openai] } }
- { name: status, in: query, schema: { $ref: '#/components/schemas/AdStatus' }, description: Filter by derived campaign status (post-aggregation) }
- { name: adAccountId, in: query, schema: { type: string }, description: "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
time series, one row per calendar day in the requested range. Use this for
dashboards that draw a daily-spend or daily-conversions chart, instead of
calling `/v1/ads/tree` once per day.
`accountId` is required. The lookup is sibling-expanded so passing the `metaads`
ID also includes ads under the linked `facebook` / `instagram` posting account
(and vice-versa), the same convention as `/v1/ads/tree` and `/v1/ads`.
Date range defaults to the last 90 days. Capped at 730 days. Ranges older
than the ingested history return a `202` immediately with the covered part
and `backfillPending: true` while the rest is backfilled in the background;
repeat the request shortly until it returns 200 with full data.
With adAccountId set to a Google customer id this is the customer-level performance report (clicks, cost, impressions, conversions, all conversions per day).
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.
Google Search ads include current creative.headlines, creative.descriptions and creative.finalUrls,
preserving pinnedField. Top-level cachedAt and stale report cache freshness. Google mutations invalidate this read.
RSA enrichment requires a stored advertisingChannelType of SEARCH. Ads with an unknown or other channel
return their stored details without a Google read. If RSA enrichment fails, the stored ad is returned
with HTTP 200 and without cache metadata.
The `{adId}` path segment accepts any identifier dialect Zernio indexes for the ad:
- the Zernio internal `_id` (24-char hex)
- Meta's numeric `platformAdId` (the value shipped in `comment.received` webhooks as `comment.ad.id`)
- the creative's `effective_object_story_id` (`{pageId}_{postId}` shape, Facebook side)
- the creative's `effective_instagram_media_id` (Instagram side)
Any of the four resolve to the same ad. Caller doesn't need a translation step.
`creative.creativeFeatures` holds the stored requested settings, which do not confirm
platform application.
security:
- bearerAuth: []
parameters:
- name: adId
in: path
required: true
schema: { type: string }
description: |
Zernio `_id` (hex), Meta `platformAdId` (numeric), or one of the creative's effective story/media IDs. See description for details.
responses:
'200':
description: Ad details
content:
application/json:
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
are propagated to the platform.
Per-platform support:
- **Meta** (Facebook + Instagram): all fields supported.
- **TikTok**: status, budget, targeting (via `/v2/adgroup/update/`), and creative
(via `/v2/ad/update/` patch-style: `headline` is ignored, `body` becomes `ad_text`).
- **Google**: status, budget, KEYWORD edits via `targeting.keywords` /
`targeting.negativeKeywords`, DEVICE bid adjustments via `targeting.devices`,
LOCATION edits via `targeting.locations` (or the equivalent top-level
`targeting.countries` / `regions` / `cities` / `zips` / `metros`), and LANGUAGE
edits via `targeting.languages`.
Each list you send becomes the FULL new set of its kind (criteria not in the
list are removed, except devices, which Google cannot remove and which are
switched off with a bid modifier of 0 instead); a kind left out is untouched.
Any other `targeting` field
returns 400: Google cannot mutate it post-create without recreating
the campaign. Creative edits are dispatched on the ad's `advertisingChannelType`,
and every supported field replaces a whole set; a field you omit is preserved.
- **Search**: top-level `headlines`, `descriptions` and `finalUrls`. Use 3-15 headlines
(1-30 characters) and 2-4 descriptions (1-90 characters). Omit an asset to remove it;
omit pinnedField on an included asset to unpin it. Updates do not pad or truncate text.
The legacy creative fields remain unsupported.
- **Display**: top-level `headlines` (1-5, no pinnedField, display ads have no pinned
positions), `descriptions` (1-5) and `finalUrls`, plus `creative.longHeadline`,
`creative.businessName`, `creative.imageUrl` (the landscape marketing image) and
`creative.squareImageUrl`. Each image URL is uploaded as a new Google asset and the ad
is pointed at it; Google assets are immutable, so the previous asset stays in the
account's asset library.
- **Performance Max**: top-level `assetGroup`, which swaps asset roles on the ad's asset
group. The other creative fields return 422 for this channel, and `assetGroup` returns
422 on any other channel.
- **LinkedIn**: status, budget, targeting (countries or regions, excludedLocations (countries),
the B2B facets, and audience segments; applied to the LinkedIn Campaign via
PARTIAL_UPDATE, and REPLACES the campaign's entire targetingCriteria, not a merge),
and creative (uploads new media, creates a replacement inline creative on the same
campaign, pauses the old one).
- **Pinterest / X / OpenAI Ads**: status + budget only. Sending
`targeting` or `creative` returns 501 with code `unsupported_platform_operation`.
OpenAI Ads budget is lifetime-only (see `budget.type` below).
**Google location and language replacement:** locations, languages and devices are
campaign-level criteria on Google, so these edits apply to every ad group and ad in
the ad's campaign. Send the complete list you want to keep. Zernio diffs it against
the campaign's live criteria and sends the removes and the creates in ONE
`googleAds:mutate`, so the campaign is never left with a half-applied set; criteria
already in the list keep their criterion ID and history. Excluded (negative)
locations are left untouched. An empty location list returns 400 (a Google campaign
with no location criteria targets every country, which is never what "remove my
locations" means, so omit the field instead). Send either `targeting.locations` or the
top-level geo fields, not both: mixing them returns 400.
**Google radius targeting:** `customLocations` is editable and is replaced the same
way, but as its OWN set. Google models a place (LOCATION) and a point plus radius
(PROXIMITY) as different criterion types, so the two are independent: sending
`customLocations` replaces every radius and leaves the cities and countries alone,
and sending places replaces those and leaves the radius alone. Send
`customLocations: []` to drop radius targeting entirely. A circle you re-send
unchanged keeps its criterion ID rather than being removed and recreated.
**Google keyword replacement:** These edits affect the ad's entire ad group,
including sibling ads. Positive (`targeting.keywords`) and negative
(`targeting.negativeKeywords`) sets are independent: omit a field to leave
that set unchanged, or send `[]` to remove every keyword of that kind.
Zernio compares each supplied set with Google's live criteria by
case-insensitive keyword text and match type. A matching criterion is left
untouched, retaining its criterion ID, enabled/paused status, keyword-level
bid overrides, labels, and criterion-associated history/statistics. Zernio
does not reset its quality score; Google continues to calculate scores and
statistics normally. Text comparison does not trim whitespace.
A bare string or an object without `matchType` means `broad`, not the
existing criterion's match type. For example, resending an existing
`{ "text": "plumber", "matchType": "exact" }` preserves it; sending
`"plumber"` instead removes that EXACT criterion and requests a BROAD one.
Changing text or match type removes criteria no longer requested and
creates any missing criteria. New criteria get new IDs and do not inherit
removed criteria's bid overrides, labels, or history. Historical reporting
for a removed criterion is not transferred to its replacement.
To add keywords without replacing a set, use
[POST /v1/ads/keywords](https://docs.zernio.com/ad-campaigns/add-ad-keywords).
Use `PATCH /v1/ads/keywords/{keywordId}` to pause/enable one keyword, or
`DELETE /v1/ads/keywords/{keywordId}` to remove it.
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
bid adjustment edits only), and LinkedIn (countries or regions required).
Pinterest / X return 501.
properties:
keywords:
type: array
description: |
Google only. The FULL desired set of positive keywords for the entire ad group.
Omit to leave positives unchanged; [] removes all positives. Negatives are independent.
Entries are strings (BROAD) or { text, matchType } with matchType exact | phrase | broad;
an omitted matchType also defaults to BROAD. Matching case-insensitive text AND match type
retains the existing criterion ID, status, bid overrides, labels and history without a mutation.
A changed text or match type uses remove/create, without transferring the old criterion's
attributes or history. See Google keyword replacement above for an EXACT-to-BROAD example.
Mirrored to GET /v1/ads/keywords immediately.
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,
independent of positives. Omit to leave negatives unchanged; [] removes all negatives.
Uses the same text/match-type identity and preservation contract as keywords above.
Strings and objects without matchType default to BROAD, so resending an EXACT or PHRASE
negative as a bare string requests a different criterion. Campaign negatives are separate:
use /v1/ads/campaigns/{campaignId}/negative-keywords to manage those.
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.
- **Meta**: patch-style. Pass any subset: fields you omit are preserved from the
live creative, including media (`image_hash`/`video_id` are reused, no re-upload)
and `url_tags`. Sending the full set (`headline`, `body`, `callToAction`,
`linkUrl`, `imageUrl`) rebuilds the creative from scratch instead. Partial
patching reads the live `object_story_spec`, which Meta strips on SHARE /
page-post / dark / asset_feed creatives. Those return 422 asking for the full
set. A `videoUrl`/`videoId` on an image creative is a type change and also
needs the full set. `existingCreativeId` repoints the ad at a creative from
GET /v1/ads/creatives and ignores every other field. Meta creatives are
immutable, so any change creates a new creative and repoints the ad; the old
creative is retained on the ad account for historical reporting.
`creativeFeatures` is Meta-only. Omitted settings are preserved from the
live creative, including full rebuilds. A supplied creativeFeatures map
overrides individual existing keys.
- **TikTok**: patch-style. Pass any subset; `headline` is ignored (TikTok creatives
have no headline slot). `body` becomes the in-feed `ad_text`; `linkUrl` becomes
`landing_page_url`; `videoUrl` triggers a fresh upload. `description`, `videoId`
and `existingCreativeId` are Meta-only and return 400.
- **LinkedIn**: requires new media (image via `imageUrl` or video via `videoUrl`);
a text-only creative update returns 400. Uploads the media, creates a new inline
media creative on the same campaign, and pauses the old creative (best-effort).
The old creative is retained for historical reporting. `videoId` and
`existingCreativeId` are Meta-only and return 400.
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' }
'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." }
'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." }
'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
campaign (so sibling ads keep running). Thin wrapper over the `status`
field of PUT /v1/ads/{adId}, for callers that want a URL symmetric to
/v1/ads/campaigns/{campaignId}/status and /v1/ads/ad-sets/{adSetId}/status.
`{adId}` accepts the same identifier dialects as GET/PUT /v1/ads/{adId}
(Zernio hex `_id`, Meta numeric `platformAdId`, or the creative's
effective story/media IDs). `platform` is inferred from the ad, so it's
not required in the body. Ads in terminal statuses (rejected, completed,
cancelled) and no-op flips (already in the target state) are skipped.
security:
- bearerAuth: []
parameters:
- { name: adId, in: path, required: true, schema: { type: string }, description: "Zernio `_id` (hex), Meta `platformAdId` (numeric), or one of the creative's effective story/media IDs." }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [status]
properties:
status: { type: string, enum: [active, paused] }
responses:
'200':
description: Ad status updated (or skipped when no change was needed)
content:
application/json:
schema:
type: object
properties:
updated: { type: integer, description: "1 when the status changed, 0 when skipped" }
skipped: { type: integer, description: "1 when skipped (terminal status or already in target state), else 0" }
message: { type: string, description: "Human-readable summary (present only when skipped)" }
'400': { description: Invalid input }
'401': { $ref: '#/components/responses/Unauthorized' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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
timeline over the requested date range (summed across the campaign's ads), and optional
demographic breakdowns. Breakdowns are fetched live from Meta at the campaign level (one call
per dimension, no per-ad fan-out), so an agency dashboard gets campaign-level age/gender/etc.
without summing thousands of per-ad reads. `campaignId` is the platform campaign id; pass
`platform` when a campaign id could be ambiguous across platforms. If no date range is provided,
defaults to the last 90 days. Date range is capped at 730 days max.
Google adds searchImpressionShare, searchBudgetLostImpressionShare,
searchRankLostImpressionShare, searchTopImpressionShare and searchAbsoluteTopImpressionShare
under analytics.summary for the requested inclusive range. These ratios are queried
together without daily segmentation and cached for 10 minutes. Unavailable values are
null. analytics.impressionShareCache reports cachedAt and stale independently of synced metrics.
security:
- bearerAuth: []
parameters:
- { name: campaignId, in: path, required: true, schema: { type: string }, description: "Platform campaign id (platformCampaignId)." }
- { name: platform, in: query, schema: { type: string }, description: "Disambiguate when the campaign id exists across platforms (e.g. facebook, instagram)." }
- { name: fromDate, in: query, schema: { type: string, format: date }, description: "Start of date range (YYYY-MM-DD). Defaults to 90 days ago." }
- { name: toDate, in: query, schema: { type: string, format: date }, description: "End of date range (YYYY-MM-DD). Defaults to today. Max 730-day range." }
- name: breakdowns
in: query
schema: { type: string }
description: |
Comma-separated breakdown dimensions.
**Meta**: age, gender, country, publisher_platform, device_platform, region,
platform_position, impression_device, video_asset, image_asset, body_asset, title_asset.
**LinkedIn** (firmographics): job_title, job_function, seniority, industry,
company, company_size, country, region. Rows carry the raw pivot `value`
plus a resolved `name`. LinkedIn serves these aggregated over the whole
range, delays the data 12-24h, and omits segments with fewer than 3 events.
responses:
'200':
description: Campaign analytics
content:
application/json:
schema: { $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
`/generatepreviews`. Provide exactly one creative source: `existingCreativeId` or `creativeSpec`.
Each preview is an HTML `<iframe>` snippet embeddable directly. Unknown `formats` values
return Meta's 400 verbatim.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId]
properties:
accountId: { type: string, description: "Zernio SocialAccount id used to resolve the Meta token." }
adAccountId: { type: string, description: "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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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' }
'429': { description: Meta rate limit reached }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/{adId}/preview:
get:
x-resource-group: "ads"
operationId: getAdPreviews
tags: ["Ad Creatives"]
x-platforms: ["meta"]
summary: Render previews of an existing ad
description: |
Renders an EXISTING ad per placement via Meta's `/{ad_id}/previews`. Each preview is an HTML
`<iframe>` snippet embeddable directly. Unknown `formats` values return Meta's 400 verbatim.
security:
- bearerAuth: []
parameters:
- { name: adId, in: path, required: true, schema: { type: string }, description: "Zernio ad id (24-char hex)." }
- { name: formats, in: query, schema: { type: string }, description: "Comma-separated Meta ad_format values (max 10), one preview per format. Defaults to DESKTOP_FEED_STANDARD." }
responses:
'200':
description: Rendered previews
content:
application/json:
schema:
type: object
properties:
adId: { type: string }
previews:
type: array
items:
type: object
properties:
format: { type: string }
html: { type: [string, "null"], description: "Meta's <iframe> snippet; null when Meta returned no preview for the format." }
'400': { description: "Invalid input, or Meta rejected the ad_format; 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
creative, normalised across shapes: single image/video, carousel,
Reels/Story (`object_story_spec.video_data`) and dynamic
creative (`asset_feed_spec`). Video items include Meta's poster thumbnail and the
video's Meta id when available.
Reads Meta live rather than the stored creative blob because Meta's signed fbcdn
URLs carry an `oe=<hex>` expiration (image_url ~24 h, video source ~12 d). Treat
URLs as short-lived: re-fetch this endpoint before serving or downloading assets
instead 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,
or both, returning idea rows verbatim (avgMonthlySearches, competition, competitionIndex,
top-of-page bid micros, monthlySearchVolumes). Counters are int64s encoded as strings; bid
values are micros of the account currency. Omitting `countries` targets worldwide.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId]
properties:
accountId: { type: string, description: "Zernio googleads SocialAccount id." }
customerId: { type: string, description: "Numeric Google Ads customer id (no dashes); only needed when the connection has several accounts." }
seedKeywords: { type: array, items: { type: string, maxLength: 80 }, minItems: 1, maxItems: 20, description: "Seed terms. Provide these, seedUrl, or both." }
seedUrl: { type: string, format: uri, description: "Landing page to mine for ideas. Provide this, seedKeywords, or both." }
countries: { type: array, items: { type: string, minLength: 2, maxLength: 2 }, minItems: 1, maxItems: 10, description: "ISO 3166-1 alpha-2 country codes. Omitted = worldwide." }
languageConstantId: { type: string, default: "1000", description: "Google languageConstant id (1000 = English)." }
network: { type: string, enum: [GOOGLE_SEARCH, GOOGLE_SEARCH_AND_PARTNERS], default: GOOGLE_SEARCH }
includeAdultKeywords: { type: boolean }
pageSize: { type: integer, minimum: 1, maximum: 10000 }
pageToken: { type: string, description: "Cursor from paging.nextPageToken of the previous page." }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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' }
'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
exact keywords: historical search volume, competition and top-of-page bid ranges, plus
averageCpcMicros when includeAverageCpc is set. Rows come back verbatim; counters are int64s
encoded as strings, bid/CPC values are micros of the account currency.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, keywords]
properties:
accountId: { type: string, description: "Zernio googleads SocialAccount id." }
customerId: { type: string, description: "Numeric Google Ads customer id (no dashes); only needed when the connection has several accounts." }
keywords: { type: array, items: { type: string, maxLength: 80 }, minItems: 1, maxItems: 1000 }
countries: { type: array, items: { type: string, minLength: 2, maxLength: 2 }, minItems: 1, maxItems: 10, description: "ISO 3166-1 alpha-2 country codes. Omitted = worldwide." }
languageConstantId: { type: string, default: "1000", description: "Google languageConstant id (1000 = English)." }
network: { type: string, enum: [GOOGLE_SEARCH, GOOGLE_SEARCH_AND_PARTNERS], default: GOOGLE_SEARCH }
includeAdultKeywords: { type: boolean }
includeAverageCpc: { type: boolean, description: "Adds averageCpcMicros to each row's keywordMetrics." }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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' }
'429': { description: "Per-user Google Ads operations budget or the shared Google quota reached; the message says which and when it resets." }
'501': { description: Only supported on Google Ads }
/v1/ads/insights:
get:
x-resource-group: "ads"
operationId: queryAdInsights
tags: ["Ad Insights"]
x-platforms: ["meta", "google"]
summary: Flexible live insights query
description: |
Live, flexible insights query. The account's platform picks the contract:
**Meta (facebook/instagram)**: forwards caller-chosen `fields`, `breakdowns` and `filtering`
to any Meta insights node and returns Meta's rows verbatim. `objectId` (required) selects the
node; `level` sets row granularity. Semantic validation is Meta's: an unknown field or invalid
breakdown combination returns a 400 carrying Meta's message. For long ranges or agency-scale
accounts prefer the async variant (POST /v1/ads/insights/reports).
**Google Ads (googleads)**: raw GAQL passthrough. Send any read-only GAQL SELECT via `query`
(campaign/keyword/search-term/geo/demographic/asset/shopping resources, `change_event`, any
`segments.*`) and rows come back verbatim (camelCase, counters as strings). Results are paged
at a fixed 10,000 rows; follow `paging.nextPageToken` with `pageToken`. `customerId` is only
needed when the connection has several Google Ads accounts. Semantic validation is Google's:
an invalid query returns a 400 carrying Google's message (note: selecting `segments.date`
requires a finite date filter).
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant); its platform selects the Meta or Google contract." }
- { name: objectId, in: query, schema: { type: string }, description: "Meta only (required there): insights node (act_<n>, campaign id, ad set id or ad id)." }
- { name: query, in: query, schema: { type: string, maxLength: 10000 }, description: "Google only (required there): the GAQL SELECT statement to run." }
- { name: customerId, in: query, schema: { type: string }, description: "Google only: numeric customer id (no dashes) when the connection has several Google Ads accounts." }
- { name: pageToken, in: query, schema: { type: string }, description: "Google only: cursor from paging.nextPageToken of the previous page." }
- { name: level, in: query, schema: { type: string, enum: [ad, adset, campaign, account] }, description: Row granularity }
- { name: fields, in: query, schema: { type: string }, description: "Comma-separated Graph insights fields (e.g. spend,impressions,frequency,website_purchase_roas). Omitted = Meta's default set." }
- { name: breakdowns, in: query, schema: { type: string }, description: "Comma-separated Graph breakdowns (e.g. age,gender or publisher_platform)." }
- { name: actionBreakdowns, in: query, schema: { type: string }, description: "Comma-separated Graph action breakdowns. Segments the actions[] arrays in each row." }
- { name: actionAttributionWindows, in: query, schema: { type: string }, description: "Comma-separated Meta attribution windows. Action values are returned keyed per window." }
- { name: actionReportTime, in: query, schema: { type: string }, description: "When actions are counted: impression, conversion or mixed." }
- { name: useUnifiedAttributionSetting, in: query, schema: { type: boolean }, description: "Use the ad sets' own attribution settings for action counting." }
- { name: filtering, in: query, schema: { type: string }, description: "JSON array of Meta filter objects: [{\"field\", \"operator\", \"value\"}]. Applied server-side by Meta." }
- { name: datePreset, in: query, schema: { type: string }, description: "Meta date_preset (e.g. last_7d, last_30d, this_month). Mutually exclusive with fromDate/toDate." }
- { name: fromDate, in: query, schema: { type: string, format: date }, description: "Start of range (YYYY-MM-DD); requires toDate." }
- { name: toDate, in: query, schema: { type: string, format: date }, description: "End of range (YYYY-MM-DD); requires fromDate." }
- { name: timeIncrement, in: query, schema: { type: string }, description: "Days per row (1-90), monthly, or all_days." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 25 }, description: Rows per page }
- { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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' }
'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
in the JSON body; Meta processes the report server-side, which is the right choice for long
ranges or large accounts where the sync query is slow or rate-limited. Returns a `reportRunId`
to poll via GET /v1/ads/insights/reports/{reportRunId}.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, objectId]
properties:
accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant)." }
objectId: { type: string, description: "Meta insights node: act_<n>, campaign id, ad set id or ad id." }
level: { type: string, enum: [ad, adset, campaign, account] }
fields: { type: string, description: "Comma-separated Graph insights fields." }
breakdowns: { type: string, description: "Comma-separated Graph breakdowns." }
actionBreakdowns: { type: string, description: "Comma-separated Graph action breakdowns (e.g. action_type,action_destination)." }
actionAttributionWindows:
type: array
items: { type: string }
description: "Meta attribution windows (e.g. [\"7d_click\", \"1d_view\"]). Action values are returned keyed per window."
actionReportTime: { type: string, description: "When actions are counted: impression, conversion or mixed." }
useUnifiedAttributionSetting: { type: boolean, description: "Use the ad sets' own attribution settings for action counting." }
filtering:
type: array
description: "Meta filter objects, applied server-side."
items:
type: object
required: [field, operator]
properties:
field: { type: string }
operator: { type: string }
value: {}
datePreset: { type: string, description: "Mutually exclusive with fromDate/toDate." }
fromDate: { type: string, format: date }
toDate: { type: string, format: date }
timeIncrement:
oneOf: [{ type: integer, minimum: 1, maximum: 90 }, { type: string, enum: [monthly, all_days] }]
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'202':
description: Report run submitted
content:
application/json:
schema:
type: object
properties:
reportRunId: { type: string }
status: { type: string, example: Job Started }
'400': { description: "Invalid input, or Meta rejected the report parameters" }
'401': { $ref: '#/components/responses/Unauthorized' }
'429': { description: Meta rate limit reached }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/insights/reports/{reportRunId}:
get:
x-resource-group: "ads"
operationId: getAdInsightsReport
tags: ["Ad Insights"]
x-platforms: ["meta"]
summary: Poll an async insights report run
description: |
Status and results for a report run created via POST /v1/ads/insights/reports. While the job
runs, returns `status` and `percentCompletion`. Once `status` is "Job Completed" the response
also carries a `data` page, cursor-paginated via `limit` / `after`.
security:
- bearerAuth: []
parameters:
- { name: reportRunId, in: path, required: true, schema: { type: string } }
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id used to resolve the Meta token (must be the same connection that created the run)." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 500, default: 25 } }
- { name: after, in: query, schema: { type: string } }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Report run status (plus results when completed)
content:
application/json:
schema:
type: object
properties:
reportRunId: { type: string }
status: { type: string, description: "Meta async_status: Job Not Started, Job Started, Job Running, Job Completed, Job Failed, Job Skipped." }
percentCompletion: { type: integer }
dateStart: { type: string }
dateStop: { type: string }
data:
type: array
items: { type: object }
description: "Present only when status is Job Completed."
paging:
type: object
properties:
after: { type: [string, "null"] }
'400': { description: "Invalid input, or the report run is not readable with this account's token" }
'401': { $ref: '#/components/responses/Unauthorized' }
'429': { description: Meta rate limit reached }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/{adId}/analytics:
get:
x-resource-group: "ads"
operationId: getAdAnalytics
tags: ["Ad Insights"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: Get ad analytics
description: |
Returns detailed performance analytics for an ad. Includes summary metrics, a daily timeline
over the requested date range, and optional demographic breakdowns (Meta and TikTok only).
If no date range is provided, defaults to the last 90 days. Date range is capped at 730 days max.
security:
- bearerAuth: []
parameters:
- { name: adId, in: path, required: true, schema: { type: string } }
- { name: fromDate, in: query, schema: { type: string, format: date }, description: "Start of date range (YYYY-MM-DD). Defaults to 90 days ago." }
- { name: toDate, in: query, schema: { type: string, format: date }, description: "End of date range (YYYY-MM-DD). Defaults to today. Max 730-day range." }
- name: breakdowns
in: query
schema: { type: string }
description: |
Comma-separated breakdown dimensions.
**Meta**: age, gender, country, publisher_platform, device_platform, region.
**TikTok**: gender, age, country_code, platform, ac, language.
**LinkedIn** (firmographics): job_title, job_function, seniority, industry,
company, company_size, country, region. Rows carry the raw pivot `value`
plus a resolved `name`. LinkedIn serves these aggregated over the whole
range, delays the data 12-24h, and omits segments with fewer than 3 events.
responses:
'200':
description: Ad analytics
content:
application/json:
schema: { $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.
- Meta (facebook/instagram): the creative's `url_tags` (and template_url_spec).
- Google (googleads): the campaign's `trackingUrlTemplate` + `finalUrlSuffix`.
Subject to the Google Ads API access-tier daily quota; bulk audits need Standard access.
- LinkedIn (linkedinads): the campaign's Dynamic UTM `dynamicValueParameters` + `customValueParameters`.
Returns 405 for platforms without a click-URL tracking surface (TikTok, X, Pinterest).
**Not pixels.** Despite the shared path segment, this endpoint has nothing to do with
measurement tags. For an ad account's pixels use
`GET /v1/accounts/{accountId}/tracking-tags?adAccountId=act_...` (Meta Pixels, with `kind`
and `ownerAdAccountId`) or `GET /v1/accounts/{accountId}/conversion-destinations`.
security:
- bearerAuth: []
parameters:
- { name: adId, in: path, required: true, schema: { type: string }, description: "Ad id (hex _id, platformAdId, or effective story/media id)." }
responses:
'200':
description: "Tracking tags for the ad's platform (shape varies by platform)."
content:
application/json:
schema:
type: object
properties:
platform: { type: string }
level: { type: string, enum: [creative, campaign] }
urlTags: { type: [string, "null"], description: "Meta: &-joined click-URL params." }
templateUrlSpec: { type: [object, "null"], description: "Meta: third-party click-tracking template (Dynamic Ads)." }
trackingUrlTemplate: { type: [string, "null"], description: "Google." }
finalUrlSuffix: { type: [string, "null"], description: "Google." }
dynamicValueParameters: { type: [object, "null"], description: "LinkedIn." }
customValueParameters: { type: [object, "null"], description: "LinkedIn." }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: "Ad not found" }
'405': { description: "Platform has no click-URL tracking surface" }
patch:
x-resource-group: "ads"
operationId: updateAdTrackingTags
tags: ["Tracking Tags"]
x-platforms: ["meta"]
summary: Set ad tracking tags
description: |
Unified update. Send only the fields for the ad's platform:
- Meta: `urlTags` (array of {key,value}). Meta creatives are immutable, so this rebuilds the
creative and repoints the ad. By DEFAULT we PRESERVE the existing creative verbatim
(re-post its object_story_spec + the new url_tags, reusing the image), so you send `urlTags`
ALONE, with no need to read back headline/body/CTA. `creative` (headline, body, callToAction,
linkUrl, imageUrl) is OPTIONAL and only needed to rebuild explicitly, or for SHARE / page-post
/ dark / asset_feed creatives whose object_story_spec Meta strips (those return 422 asking for
`creative`).
- Google: `trackingUrlTemplate` and/or `finalUrlSuffix` (full template strings; account quota applies).
- LinkedIn: `dynamicValueParameters` and/or `customValueParameters` (campaign-level Dynamic UTM).
security:
- bearerAuth: []
parameters:
- { name: adId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
properties:
urlTags:
type: array
description: "Meta only. Click-URL params appended to a freshly-rebuilt creative. Meta dynamic macros ({{ad.id}}, {{campaign.id}}, {{placement}}, ...) are sent through unescaped so Meta expands them; every other character is percent-encoded."
items:
type: object
required: [key, value]
properties:
key: { type: string }
value: { type: string }
creative:
type: object
description: "Meta only. OPTIONAL: omit to preserve the existing creative verbatim (default). Provide it only to rebuild the creative explicitly, or for creatives whose object_story_spec Meta strips."
required: [headline, body, callToAction, linkUrl, imageUrl]
properties:
headline: { type: string }
body: { type: string }
callToAction: { type: string }
linkUrl: { type: string, format: uri }
imageUrl: { type: string, format: uri }
videoUrl: { type: string, format: uri }
trackingUrlTemplate: { type: string, description: "Google only. Full tracking template (must contain {lpurl})." }
finalUrlSuffix: { type: string, description: "Google only. Parse-only key=value params." }
dynamicValueParameters: { type: object, additionalProperties: { type: string }, description: "LinkedIn only. key -> dynamic value enum (CAMPAIGN_ID, CAMPAIGN_NAME, CREATIVE_ID, ...)." }
customValueParameters: { type: object, additionalProperties: { type: string }, description: "LinkedIn only. key -> static value." }
responses:
'200':
description: |
The tags as they now stand, in the same shape the GET on this path returns:
`platform` plus the fields that platform supports. Meta returns `level`,
`urlTags` and `templateUrlSpec`; Google returns `trackingUrlTemplate` and
`finalUrlSuffix`. A field the platform does not support is absent.
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
engagement on dark posts (ad creatives that never went live organically), which the
regular GET /v1/inbox/comments/{postId} endpoint cannot serve because dark posts are
not in Zernio's post database.
An ad that runs on both Facebook feed and Instagram feed has two separate underlying
posts with separate comment threads (the creative's effective_object_story_id and
effective_instagram_media_id). Use the `placement` query param to pick one; with no
param the Instagram side is returned when it exists, otherwise Facebook. The
identifiers are read from the ad record (persisted during sync) with a Marketing-API
fallback for ads that predate the field.
For Instagram-placed comments, the Instagram account that runs the ad must be connected
to Zernio, because those comments are read through that account's token. If no connected
Instagram account on the profile can read the ad's media, the call returns
ads_connection_required (the Facebook side, if any, is still readable via ?placement=facebook).
TikTok uses the connected TikTok Ads advertiser token and supports both paid video
ads and Spark Ads. `since` and `until` select a date window of at most 30 days;
the default is the last 30 days. TikTok searches by ad group, so Zernio filters
each page to this ad. A page can be empty while `pagination.hasMore` is true.
Reuse `pagination.cursor` with the same `limit`; the cursor retains the date window.
`placement` is Meta-only and returns a 400 for TikTok.
Listing needs no identity or video item ID. When the ad group is stored, each
page makes one comment-list call and no ad-detail lookup, including for external
ads that TikTok no longer returns from ad details. `meta.tiktokItemId: null`
does not prevent listing. If the ad group is missing, Zernio fetches ad details;
unavailable details return 404 ad_not_found, and no ad group returns 400 ad_not_commentable.
TikTok returns replies as separate comments with `parentId`; nested reply fetching
is not supported. `canReply` requires a first-level comment, comment-management
permission, a video item ID and a supported TT_USER or CUSTOMIZED_USER identity.
`canDelete` requires TikTok's own-comment deletion capability, a video item ID
and a supported identity. Both flags are false when identity or item is unknown.
Listing uses stored and comment-specific fields without fetching identity.
A direct reply or delete request can lazily resolve missing fields and succeed
even after a false flag. `canHide` is true because visibility changes need only
advertiser and comment IDs. `canLike` is false. Use the ad comment reply, hide
and delete operations below to moderate TikTok comments.
Other platforms return feature_not_available.
Requires the Ads add-on. Response shape matches GET /v1/inbox/comments/{postId}.
The `{adId}` path segment accepts any identifier dialect Zernio indexes for the ad:
Zernio internal `_id` (24-char hex), the numeric `platformAdId` (the value shipped in
`comment.received` webhooks as `comment.ad.id`), or the creative's
`effective_object_story_id` / `effective_instagram_media_id`. Caller doesn't need a
translation step.
security:
- bearerAuth: []
parameters:
- { name: adId, in: path, required: true, schema: { type: string }, description: "Internal Zernio ad ID 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
underlying post (code ad_not_commentable).
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required (legacy plans need the Ads add-on; included by default on usage-based plans), or ad platform is not Meta or TikTok (code feature_not_available).
'404': { $ref: '#/components/responses/NotFound' }
'422':
description: |
Ads account token unavailable, or (for Instagram-placed ads) no connected
Instagram account on the profile can read the ad's media (code ads_connection_required).
/v1/ads/{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.
Unknown identity and video item fields are resolved only when needed for this
action, then persisted for reuse. Comment-specific fields take precedence.
If TikTok no longer returns the ad needed to resolve identity, 404 ad_not_found
directs you to check deletion or archival in TikTok Ads Manager. Listing can
still succeed. Unsupported or unavailable identity returns 403 feature_not_available.
Denied access to ad details returns 403 insufficient_permissions with reconnect
guidance and the upstream platformError.
Requires Ads access. The ad is resolved within the caller's accessible profiles.
Before moderation, Zernio verifies that the comment belongs to this ad using
TikTok's ad-group comment listing. The default search window is the last 30 days.
Use since/until for older comments, with at most 30 days between the dates.
Lookups scan at most 2,000 ad-group comments; narrow the date window if exceeded.
Meta returns 501 feature_not_available with guidance to use the existing inbox
comment endpoints and the account/post IDs from GET /v1/ads/{adId}/comments.
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.
Requires Ads access. The ad is resolved within the caller's accessible profiles.
Before moderation, Zernio verifies that the comment belongs to this ad using
TikTok's ad-group comment listing. The default search window is the last 30 days.
Use since/until for older comments, with at most 30 days between the dates.
Lookups scan at most 2,000 ad-group comments; narrow the date window if exceeded.
Meta returns 501 feature_not_available with guidance to use the existing inbox
comment endpoints and the account/post IDs from GET /v1/ads/{adId}/comments.
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.
Unknown identity and video item fields are resolved only when needed for this
action, then persisted for reuse. Comment-specific fields take precedence.
If TikTok no longer returns the ad needed to resolve identity, 404 ad_not_found
directs you to check deletion or archival in TikTok Ads Manager. Listing can
still succeed. Unsupported or unavailable identity returns 403 feature_not_available.
Denied access to ad details returns 403 insufficient_permissions with reconnect
guidance and the upstream platformError.
Requires Ads access. The ad is resolved within the caller's accessible profiles.
Before moderation, Zernio verifies that the comment belongs to this ad using
TikTok's ad-group comment listing. The default search window is the last 30 days.
Use since/until for older comments, with at most 30 days between the dates.
Lookups scan at most 2,000 ad-group comments; narrow the date window if exceeded.
Meta returns 501 feature_not_available with guidance to use the existing inbox
comment endpoints and the account/post IDs from GET /v1/ads/{adId}/comments.
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.
Each BC reports its advertiser count so callers can build agency-style pickers
without re-walking `/v1/ads/accounts` per BC.
TikTok-only. Solo advertisers (non-agency tokens) return an empty array.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: ID of the `tiktokads` (or parent `tiktok` posting) SocialAccount }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'422': { description: TikTok Ads not connected }
/v1/ads/activity:
get:
x-resource-group: "ads"
operationId: getAdsActivityLog
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Ad account change / audit log
description: |-
Account-level audit log from Meta's `/act_X/activities`: who changed what and when
(creates, edits, status flips, budget changes...) with Meta's translated event names and
the structured before/after in `extra_data`. Rows are returned verbatim. Meta has no
server-side per-object filter on this edge, so `objectId` filters the returned page
client-side (combine with paging to walk history for one campaign/ad set/ad).
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
- { name: since, in: query, schema: { type: string, format: date }, description: "Start of range (YYYY-MM-DD)." }
- { name: until, in: query, schema: { type: string, format: date }, description: "End of range (YYYY-MM-DD)." }
- { name: objectId, in: query, schema: { type: string }, description: "Client-side filter to one Meta object id (campaign, ad set or ad)." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 200, default: 50 }, description: Rows per page }
- { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Activity rows (raw Meta shape)
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
data:
type: array
items: { type: object, description: "Raw Meta activity row: event_type, translated_event_type, event_time, actor_name, object_id, object_name, object_type, application_name, extra_data (JSON string with before/after)." }
paging:
type: object
properties:
after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/rf-predictions:
post:
x-resource-group: "ads"
operationId: createRfPrediction
tags: ["Reach and Frequency"]
x-platforms: ["meta"]
summary: 'Create reach-frequency prediction'
description: |-
Creates an R&F prediction. This is a QUOTE, nothing is bought and no ad entities are created.
Provide a date range plus exactly one of `budgetAmount` (Meta predicts reach) or `reach`
(Meta predicts the budget). The response carries the estimate and its allowed bounds
(min/max budget and reach). Predictions expire on their own; to buy, reserve one via
POST /v1/ads/rf-predictions/{predictionId}/reserve and pass the RESERVED id to
POST /v1/ads/create with `buyingType: "RESERVED"`.
Reservation campaigns reject automatic placements. Top-level `placements` wins; when it is
omitted, `targeting.placements` is used; when neither is set, placements default to
Facebook feed (+ Instagram stream when a linked IG professional account resolves).
Instagram placements require that IG account.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, startDate, endDate]
properties:
accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant)." }
adAccountId: { type: string, description: "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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Prediction status and estimates
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
currency: { type: string }
prediction: { $ref: '#/components/schemas/RfPrediction' }
'400': { description: Invalid input }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
delete:
x-resource-group: "ads"
operationId: cancelRfReservation
tags: ["Reach and Frequency"]
x-platforms: ["meta"]
summary: 'Cancel 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Reservation cancelled
'400': { description: "Invalid input, or Meta rejected the cancel" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/rf-predictions/{predictionId}/reserve:
post:
x-resource-group: "ads"
operationId: reserveRfPrediction
tags: ["Reach and Frequency"]
x-platforms: ["meta"]
summary: 'Reserve reach-frequency inventory'
description: |-
Locks the quoted price + inventory until the returned `expiresAt` and mints a NEW
prediction id. Pass that RESERVED id (not the original) as `rfPredictionId` on
POST /v1/ads/create. Release an unused reservation via DELETE.
security:
- bearerAuth: []
parameters:
- { name: predictionId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId]
properties:
accountId: { type: string }
adAccountId: { type: string }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'201':
description: Reserved; `prediction.predictionId` is the new RESERVED id
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
prediction: { $ref: '#/components/schemas/RfPrediction' }
'400': { description: "Invalid input, or Meta rejected the reserve" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/studies:
get:
x-resource-group: "ads"
operationId: listAdStudies
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: A/B tests and lift studies
description: |-
Lists the ad account's A/B tests and lift studies (Meta's `/act_X/ad_studies`), rows
returned verbatim. The default projection covers id, name, type, timing and cells with
split percentages; `fields` is a raw-passthrough override.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
- { name: fields, in: query, schema: { type: string, 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Ad studies (raw Meta shape)
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
data:
type: array
items: { type: object, description: "Raw Meta ad study row." }
paging:
type: object
properties:
after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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`),
rows returned verbatim (id, name, verification_status, created_time). Token-scoped, so no
`adAccountId` is needed. For TikTok Business Centers use
`GET /v1/ads/business-centers`.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
- { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Businesses (raw Meta shape)
content:
application/json:
schema:
type: object
properties:
data:
type: array
items: { type: object, description: "Raw Meta business row (id, name, verification_status, created_time)." }
paging:
type: object
properties:
after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/labels:
get:
x-resource-group: "ads"
operationId: listAdLabels
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Ad labels
description: |-
Lists the ad account's organizational labels (Meta's `/act_X/adlabels`), rows returned
verbatim (id, name, created/updated time).
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
- { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Ad labels (raw Meta shape)
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
data:
type: array
items: { type: object, description: "Raw Meta ad label row (id, name, created_time, updated_time)." }
paging:
type: object
properties:
after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/high-demand-periods:
get:
x-resource-group: "ads"
operationId: listHighDemandPeriods
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: 'List high-demand periods'
description: |-
Scheduled budget increases (Meta's budget-scheduling API). The Graph edge lives on the
campaign and ad-set nodes only, so exactly one of `campaignId` / `adSetId` (platform
ids) is required. Rows returned verbatim (budget_value, budget_value_type, time window,
recurrence).
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: campaignId, in: query, schema: { type: string }, description: "Platform campaign id. Exactly one of campaignId / adSetId." }
- { name: adSetId, in: query, schema: { type: string }, description: "Platform ad set id. Exactly one of campaignId / adSetId." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
- { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page." }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Budget schedules (raw Meta shape)
content:
application/json:
schema:
type: object
properties:
objectId: { type: string, description: "The campaign / ad set id the schedules belong to." }
data:
type: array
items: { type: object, description: "Raw Meta high-demand-period row." }
paging:
type: object
properties:
after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
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
editing the budget by hand on the day. Same target rule as the GET: exactly one of
`campaignId` / `adSetId`.
Two Meta constraints worth knowing before you call it. `timeStart` / `timeEnd` must
fall on a 15-minute boundary, and a campaign cannot mix `ABSOLUTE` and `MULTIPLIER`
across its schedules; the second type is rejected with "Can't mix your budget scaling
selection". Window rules (must sit inside the campaign's run dates, minimum lead time,
no 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/creatives:
get:
x-resource-group: "ads"
operationId: listAdCreatives
tags: ["Ad Creatives"]
x-platforms: ["meta"]
summary: Creative library
description: |-
Lists the ad account's creative library (Meta's `/act_X/adcreatives`), rows returned
verbatim. The default projection covers id, name, status, object type, thumbnail,
object_story_spec / asset_feed_spec and url_tags; `fields` is a raw-passthrough
override. Any creative id here is reusable on the create endpoints via
`existingCreativeId`.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
- { name: fields, in: query, schema: { type: string, 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Creatives (raw Meta shape)
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
data:
type: array
items: { type: object, description: "Raw Meta creative row." }
paging:
type: object
properties:
after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
post:
x-resource-group: "ads"
operationId: createAdCreative
tags: ["Ad Creatives"]
x-platforms: ["meta"]
summary: Create a standalone creative
description: |-
Creates a creative in the library WITHOUT an ad, reusable on the create endpoints via
`existingCreativeId`. Provide exactly one of `imageUrl` (uploaded server-side),
`imageHash` (from POST /v1/ads/images or the library list), or `carouselCards` (2-10
hand-built cards). The Page (and linked Instagram account, when present) is resolved
from `accountId` as the story actor. `creativeFeatures` configures Advantage+
enhancements. `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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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' }
'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
the default projection.
security:
- bearerAuth: []
parameters:
- { name: creativeId, in: path, required: true, schema: { type: string }, description: Platform creative id }
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: fields, in: query, schema: { type: string, 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Creative details
content:
application/json:
schema:
type: object
properties:
creative: { type: object, description: Raw Meta creative node }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
put:
x-resource-group: "ads"
operationId: updateAdCreative
tags: ["Ad Creatives"]
x-platforms: ["meta"]
summary: Rename a creative
description: |-
Renames a creative. Creatives are immutable on Meta beyond `name`. For content changes
create a new creative (POST /v1/ads/creatives) and swap it onto the ad
(PUT /v1/ads/{adId} with `creative`).
security:
- bearerAuth: []
parameters:
- { name: creativeId, in: path, required: true, schema: { type: string }, description: Platform creative id }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, name]
properties:
accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant); its platform decides where the campaign is created." }
name: { type: string, maxLength: 255 }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Creative renamed
content:
application/json:
schema:
type: object
properties:
creativeId: { type: string }
name: { type: string }
message: { type: string }
'400': { description: "Invalid input, or Meta rejected the update" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
delete:
x-resource-group: "ads"
operationId: deleteAdCreative
tags: ["Ad Creatives"]
x-platforms: ["meta"]
summary: Delete a creative
description: |-
Deletes a creative from the library. Meta only allows deleting creatives not referenced
by any ad; otherwise its 400 surfaces verbatim.
security:
- bearerAuth: []
parameters:
- { name: creativeId, in: path, required: true, schema: { type: string }, description: Platform creative id }
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Creative deleted
content:
application/json:
schema:
type: object
properties:
creativeId: { type: string }
message: { type: string }
'400': { description: "Invalid input, the creative is in use, or Meta rejected the delete" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/value-rule-sets:
get:
x-resource-group: "ads"
operationId: listValueRuleSets
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: List value rule sets
description: |-
Lists the ad account's value rule sets (Meta's `/act_X/value_rule_set`). A value rule
set adjusts the auction bid up or down for audience segments you value differently;
attach one to an ad set with `valueRuleSetId` on `POST /v1/ads/create` or
`PUT /v1/ads/ad-sets/{adSetId}`.
Rows are returned in the same camelCase shape the `PUT` body takes, ids included, so a
set round-trips 1:1: **the update is a full replace, not a patch**, so you GET, mutate
and send the whole thing back.
Limits: 6 rule sets per ad account, 10 rules per set, 4 criteria per rule.
**Rule order is semantic.** Rules are evaluated in array order and only the FIRST
matching rule adjusts the bid for an overlapping audience. The order you send is the
order that is stored and returned.
Eligibility: value rule sets apply only to ad sets on the `LOWEST_COST_WITHOUT_CAP`
(auto-bid) or `COST_CAP` bid strategies. Meta rejects the rest server-side.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
- { name: limit, in: query, schema: { type: integer, minimum: 1, maximum: 100, default: 25 }, description: Rows per page }
- { name: after, in: query, schema: { type: string }, description: "Cursor from paging.after of the previous page. Meta does not document paging on this edge; `after` comes back null when it omits cursors." }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Value rule sets
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
data:
type: array
items: { $ref: '#/components/schemas/ValueRuleSet' }
paging:
type: object
properties:
after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted or when Meta omits paging." }
'400': { description: "Invalid input, or Meta rejected the query. Meta answers a bad rule-set id with GraphMethodException code 100 / subcode 33, which is indistinguishable between not-found, no-permission, and account-not-enabled." }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
post:
x-resource-group: "ads"
operationId: createValueRuleSet
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Create a value rule set
description: |-
Creates a value rule set on the ad account (Meta's `POST /act_X/value_rule_set`).
Attach the returned id to an ad set with `valueRuleSetId` on `POST /v1/ads/create` or
`PUT /v1/ads/ad-sets/{adSetId}`.
**Rule order is semantic**: rules are evaluated in array order and only the first
matching rule adjusts the bid for an overlapping audience.
`adjustValue` is an unsigned magnitude in percent; the direction lives in `adjustSign`.
`INCREASE` accepts 1-1000, `DECREASE` accepts 1-90. There is no signed field and 0 is
out of range.
`criteriaValueTypes` is positionally paired with `criteriaValues` (same length, same
order). Every type is the literal `"NONE"` except on `LOCATION`, which uses
`LOCATION_COUNTRY` / `LOCATION_REGION` / `LOCATION_CITY` / `LOCATION_COMSCORE_MARKET`
and may mix them within one criterion. Location values are Targeting-Search keys: a
two-letter country code for `LOCATION_COUNTRY`, a numeric key for the rest.
`LOCATION_DMA` was replaced by `LOCATION_COMSCORE_MARKET` on 2026-06-22 and rules using
DMAs are no longer active, so this API rejects it.
`AUDIENCE_LABEL` values (e.g. `HIGH_VALUE`) are applied to a Custom Audience in Ads
Manager. There is no API to provision them, so label strings are passed through
unvalidated and a typo produces a rule that never fires.
Ads Manager turns a rule set read-only (this API stays editable) when a rule uses more
than 2 criteria, a custom age range, or the placements `FB_MARKETPLACE`, `FB_SEARCH`,
`FB_VIDEO` or `IG_EXPLORE`.
Limits: 6 rule sets per ad account, 10 rules per set, 4 criteria per rule. The
per-account cap is enforced by Meta, not here.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, name, rules]
properties:
accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant); 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'201':
description: Value rule set created
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
valueRuleSetId: { type: [string, "null"], description: "The new rule set id. Meta does not document the create response body, so this is null on the (unobserved) case where it omits the id." }
'400': { description: "Invalid input, or Meta rejected the create (per-account rule-set cap, ineligible criteria, or an account that is not enabled for value rules)" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/value-rule-sets/{valueRuleSetId}:
get:
x-resource-group: "ads"
operationId: getValueRuleSet
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Read a value rule set
description: |-
Reads one value rule set including every nested rule id and criterion id. This is step
one of any edit: `PUT` is a full replace, so you need the ids before you can keep the
objects you are not changing.
Meta's own read returns `GENDER` values lowercase (`"male"`) while writes require
`"MALE"`. Values are passed through untouched, so never case-compare a stored rule
against a fetched one.
security:
- bearerAuth: []
parameters:
- { name: valueRuleSetId, in: path, required: true, schema: { type: string }, description: "Platform value rule set id." }
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Value rule set
content:
application/json:
schema:
type: object
properties:
valueRuleSet: { $ref: '#/components/schemas/ValueRuleSet' }
'400': { description: "Invalid input, or Meta rejected the read. A bad id comes back as GraphMethodException code 100 / subcode 33, which cannot be told apart from a permission problem." }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
put:
x-resource-group: "ads"
operationId: updateValueRuleSet
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Replace a value rule set
description: |-
**THIS IS A FULL REPLACE, NOT A PATCH.** Meta's update is declarative: the body you
send becomes the rule set.
- `GET /v1/ads/value-rule-sets/{valueRuleSetId}` FIRST.
- Keep a rule or criterion by echoing its `id`.
- Create one by including the object WITHOUT an `id`.
- Delete one by OMITTING it from the array. There is no warning and no undo.
`name` and `rules` are both required for exactly this reason: a partial body would
silently destroy every rule left out.
**Rule order is semantic**: the array order you send is the evaluation order, and only
the first matching rule adjusts the bid for an overlapping audience.
Existing rule sets created elsewhere may contain `LOCATION_DMA` criteria. Those went
inert on 2026-06-22 and are rejected here; migrate them to `LOCATION_COMSCORE_MARKET`.
security:
- bearerAuth: []
parameters:
- { name: valueRuleSetId, in: path, required: true, schema: { type: string }, description: "Platform value rule set id." }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, name, rules]
properties:
accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant); 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Value rule set replaced
content:
application/json:
schema:
type: object
properties:
valueRuleSetId: { type: string }
name: { type: string }
rules:
type: array
items: { $ref: '#/components/schemas/ValueRule' }
message: { type: string }
'400': { description: "Invalid input, or Meta rejected the update" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
delete:
x-resource-group: "ads"
operationId: deleteValueRuleSet
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Delete a value rule set
description: |-
Deletes the rule set (Meta's `POST /{value-rule-set-id}/delete_rule_set`, a custom
action edge rather than an HTTP DELETE on its side). Ad sets pointing at it are not
modified here; detach them first with `valueRulesApplied: false` on
`PUT /v1/ads/ad-sets/{adSetId}`.
security:
- bearerAuth: []
parameters:
- { name: valueRuleSetId, in: path, required: true, schema: { type: string }, description: "Platform value rule set id." }
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Value rule set deleted
content:
application/json:
schema:
type: object
properties:
valueRuleSetId: { type: string }
message: { type: string }
'400': { description: "Invalid input, or Meta rejected the delete. A bad id comes back as GraphMethodException code 100 / subcode 33, which reads like a permission error rather than a 404." }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/accounts/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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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
`spendCap` (null = no cap) and the `fundingSource`. Money values are converted from
Meta's minor units to whole units of `currency`.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Account finances
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
currency: { type: string, description: "ISO 4217 code all money values are expressed in." }
balance: { type: number, description: "Outstanding/prepaid balance in whole currency units." }
amountSpent: { type: number, description: "Lifetime amount spent in whole currency units." }
spendCap: { type: [number, "null"], description: "Account spend cap; null when none is set." }
fundingSource:
type: [object, "null"]
properties:
displayString: { type: string, description: "Human-readable payment method, e.g. 'Mastercard *4985'." }
type: { type: integer, description: "Meta funding source type code." }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/accounts:
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
their connected Meta Ads token. Requires an active metaads accountId, Ads access,
business_management permission and business admin access. Discover portfolios with
GET /v1/ads/businesses. System-user tokens may return an empty businesses list;
supply the known business ID in that case.
The self-serve account starts without a payment method. The user must add a payment
method in Ads Manager before ads can deliver. Zernio cannot add payment methods.
Meta may require business verification and limits how many accounts a business can
create. Closing an account does not guarantee more capacity. An ad account cannot
truly be deleted, even after closing it and removing it from a business.
timezoneId is Meta's numeric ID, not an IANA timezone name. Select it from
https://developers.facebook.com/docs/marketing-api/reference/ad-account/timezone-ids/.
For example, 1 is America/Los_Angeles. Meta validates supported currencies and IDs.
endAdvertiser, mediaAgency and partner default to NONE for the self-serve flow.
The new account is added atomically to an existing scoped ad-account allowlist.
Unrestricted connections stay unrestricted. Reconnecting the same Meta identity
preserves this scope unless a caller explicitly replaces it. Discovery is nudged
immediately. Use the returned adAccountId with the existing ads endpoints.
This operation is not idempotent and Zernio never automatically retries it.
Unknown body fields are rejected. No validateOnly or dry-run option is supported.
After a timeout or a 502 with details.creationStatus=unknown, check the business
in Ads Manager before attempting another creation. A 201 with connectionUpdated=false
means the account exists but needs reconnecting with adAccountIds containing the returned ID and the previous
scoped IDs via GET /v1/connect/facebook/ads. Do not repeat the create call.
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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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
accounts, TikTok advertiser IDs, Google Ads customer IDs).
Meta business-login accounts use their own system-user token. Fresh Meta discovery
includes businessId and businessName from the owning Business Manager when available;
cached entries gain these fields after the next discovery refresh.
For TikTok agencies: enumerates every advertiser under every Business Center the token
can read (paginated server-side), then chunks the lookup against TikTok's
`/advertiser/info/` endpoint (which has a per-call cap of ≤100 IDs). Solo advertisers
without a BC fall back to the OAuth-time `advertiser_ids` list. Cached for 1h on the
SocialAccount; lazy-refreshed on first call after expiry.
For Google Ads: responds `429` when Google's API quota is temporarily exhausted
(instead of an empty list). Retry after a delay.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'400': { $ref: '#/components/responses/BadRequest' }
'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
declared because the type differs per platform: Meta sends an integer,
Google, TikTok and Pinterest send a string. Absent on LinkedIn (reports
`status`) and on X (reports `approvalStatus`).
If all you need is whether the account can run ads right now, read
`selectable` and skip this field. Read this one when you need to tell
the states apart, because they call for different responses:
- `1` ACTIVE. Running normally.
- `2` DISABLED. Disabled by Meta. Read `disableReason` to tell a policy
action apart from a billing one; they need very different follow-ups.
- `3` UNSETTLED. There is an unpaid balance, but the account still runs
ads. Not a ban.
- `7` PENDING_RISK_REVIEW. Meta is reviewing the account. Wait for the
outcome.
- `8` PENDING_SETTLEMENT. Meta blocks new ads until an outstanding
balance clears. Settle it and the account runs again.
- `9` IN_GRACE_PERIOD. Still running, on a deadline.
- `100` PENDING_CLOSURE. Scheduled to close.
- `101` CLOSED. Terminal.
approvalStatus: { type: string, description: "X only. X's own ad account approval status. Observed values are `ACCEPTED`, `PENDING` and `REJECTED`, but X does not publish the full vocabulary, so treat an unrecognised value as not usable. Other platforms report `accountStatus` or `status` instead." }
disableReason: { type: integer, description: "Meta only. Meta's `disable_reason` code, forwarded unchanged. Present when `accountStatus` is `2` (DISABLED) and Meta gives a reason, which is what separates a policy action from a payment problem. Meta does not publish a stable list of values for this field, so none are enumerated here: resolve the code against Meta's own ad account reference. Absent when Meta reports no reason, or when the connected token cannot read the field." }
timezoneName: { type: string, description: "IANA timezone of the ad account (Meta only). Drives daily-budget reset and Insights day boundaries." }
timezoneOffsetHoursUtc: { type: number, description: "Signed UTC offset in hours, reflecting current DST (Meta only)." }
minimumDailyBudget: { type: number, description: "Meta only. Minimum daily budget for the account, in the account currency's major units. This is the impressions-billed minimum; other billing events have higher minimums. Absent when the connected token cannot read it." }
selectable: { type: boolean, description: "Meta and X only. Whether the account can create/run ads now. Absent (treat as true) on other platforms." }
unusableReason: { type: [string, "null"], description: "Meta and X only. Human-readable reason when selectable is false; null when selectable." }
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." }
'401': { $ref: '#/components/responses/Unauthorized' }
'422':
description: Platform ads connection required (TikTok Ads, X Ads) or Instagram missing linked Facebook account
'429': { $ref: '#/components/responses/RateLimited' }
patch:
x-resource-group: "ads"
operationId: updateAdAccount
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Update ad account settings
description: |
Sets the default DSA beneficiary and payor on a Meta ad account (EU DSA, Article 26).
Set them once and every EU-targeted call to `/v1/ads/create`, `/v1/ads/boost` and
`/v1/ads/ctwa` on that ad account can omit `dsaBeneficiary`/`dsaPayor`: Meta applies
the defaults automatically.
The values are written to the ad account on Meta, the same setting Ads Manager edits.
Nothing is stored in Zernio, and defaults already set in Ads Manager work identically.
Zernio never guesses these values for you. Beneficiary and payor are legal disclosures
shown to EU users, so you must provide the entity names explicitly. Use
`GET /v1/ads/dsa-recommendations` to offer suggestions in your UI.
If `defaultDsaPayor` is omitted, the beneficiary is also set as the payor, which
covers the common case where the same entity benefits from and pays for the ads.
Read the current values back with `GET /v1/ads/dsa-defaults`.
Currently supported for Meta accounts only; other platforms return 400.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, defaultDsaBeneficiary]
properties:
accountId: { type: string, description: "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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
/v1/ads/dsa-defaults:
get:
x-resource-group: "ads"
operationId: getDsaDefaults
tags: ["Ad Accounts"]
x-platforms: ["meta"]
summary: Get ad account DSA defaults
description: |
Returns the default DSA beneficiary and payor currently set on a Meta ad account,
whether they were set via `PATCH /v1/ads/accounts` or in Meta Ads Manager. Fields
are omitted when no default is configured. Meta accounts only.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
/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
from the account's recent activity. Useful for prefilling `dsaBeneficiary`/`dsaPayor`
inputs, or the defaults sent to `PATCH /v1/ads/accounts`, in your own UI.
Meta returns a single flat list. Entries are not labeled as beneficiary or payor,
and since these are legal disclosures Zernio never applies them automatically: let
your user pick the right entity. The list may be empty for accounts with little
activity. Meta accounts only.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
/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
engagement. By default it provisions the whole hierarchy (campaign, ad
set, ad).
**Attach shape (Meta).** Send `adSetId` to put the ad under an EXISTING
ad set instead, so that ad set keeps its learning phase. It then owns
`budget`, `schedule` and `targeting`, and sending any of those alongside
`adSetId` is a 400 rather than a silent drop. `budget` is required only
without `adSetId`.
`instagramAccountId`, `destinationType`, `whatsappPhoneNumber` and `adSetId`
are Meta-only and return 400 on other platforms.
**Messaging boosts (Meta).** Use `goal: engagement` with
`callToAction: WHATSAPP_MESSAGE`, `MESSAGE_PAGE`, or `INSTAGRAM_MESSAGE`.
The CTA implies WHATSAPP, MESSENGER, or INSTAGRAM_DIRECT respectively;
`destinationType` alone does not select a messaging CTA. Omit `linkUrl`
only for messaging CTAs. Plain link CTAs keep their goal and link behavior
when combined with an independent `destinationType`.
The campaign uses OUTCOME_ENGAGEMENT and the ad set uses CONVERSATIONS
with the promoted Page. Optional `whatsappPhoneNumber` selects a number
already paired with that Page. Conflicting CTA/destination, instant form,
goal, or optimizationGoal inputs return 400. Attach requires the target
ad set destination to match. Existing post references preserve social proof;
an Instagram reel rejected by Meta is not re-uploaded as a new post for
a messaging boost.
**Retries.** Boosts are NOT idempotent and can take minutes when Meta requires re-hosting an
Instagram video, so do not retry on client timeout. Send an
Idempotency-Key header to make retries safe: same key and body replays
the original 201, and distinct keys always create distinct ads.
Without the header, an identical request is treated as a retry: while
one is in flight it returns 409, and within 10 minutes of a completed
boost it returns the already-created ad instead of creating another.
To intentionally duplicate an ad, send distinct Idempotency-Keys (or
vary the body, e.g. the name).
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.
Geo keys (`regions`/`cities`/`zips`/`metros`) resolve via
GET /v1/ads/targeting/search?dimension=geo. City radius and lat/lng
`customLocations` are Meta-only and preserve the boosted post's
social proof (the ad references the existing post).
properties:
ageMin: { type: integer, minimum: 13, maximum: 65 }
ageMax: { type: integer, minimum: 13, maximum: 65 }
gender: { type: string, enum: [all, male, female], description: "Applied on Meta, TikTok and Pinterest. Ignored on Google, LinkedIn and X." }
languages: { type: array, items: { type: string }, description: "Meta locale ids (numeric), passed through as given." }
countries: { type: array, items: { type: string }, description: "ISO country codes. Required for TikTok boosts (TikTok's ad group requires location_ids); optional on other platforms." }
regions:
type: array
description: "Region/state targeting. `key` from /v1/ads/targeting/search?dimension=geo&geoType=region."
items:
type: object
required: [key]
properties:
key: { type: string }
name: { type: string }
cities:
type: array
description: "City targeting. Optional `radius` + `distanceUnit` extend beyond the city limits (both set together, Meta only)."
items:
type: object
required: [key]
properties:
key: { type: string }
name: { type: string }
radius: { type: number, description: "Requires distanceUnit. Meta enforces a minimum city radius (~17 km / 10 mi); smaller values resolve to a 0-size audience and the ad fails at launch. For a tighter catchment use customLocations (lat/lng)." }
distanceUnit: { type: string, enum: [mile, kilometer] }
zips:
type: array
description: "Postal/ZIP targeting. `key` is the platform's postal location ID (e.g. Meta `US:94304`)."
items:
type: object
required: [key]
properties:
key: { type: string }
name: { type: string }
metros:
type: array
description: "DMA / metro-area targeting. `key` is the platform's metro ID (e.g. Meta `DMA:807`)."
items:
type: object
required: [key]
properties:
key: { type: string }
name: { type: string }
customLocations:
type: array
description: "Point-radius (lat/lng) targeting (Meta custom_locations). No geo `key` lookup needed."
items:
type: object
required: [latitude, longitude, radius, distanceUnit]
properties:
latitude: { type: number, minimum: -90, maximum: 90 }
longitude: { type: number, minimum: -180, maximum: 180 }
radius: { type: number }
distanceUnit: { type: string, enum: [mile, kilometer] }
name: { type: string }
address: { type: string, description: "Optional label, sent to Meta as `address_string`." }
interests:
type: array
description: "Interest objects from /v1/ads/interests. Each must include id and name."
items:
type: object
required: [id, name]
properties:
id: { type: string }
name: { type: string }
advantage_audience: { type: integer, enum: [0, 1], description: "Meta only. 0 = disabled (default), 1 = enabled." }
rawTargeting:
type: object
additionalProperties: true
description: |
Meta only. A Meta-native targeting spec (e.g.
`{ "geo_locations": { "cities": [{ "key": "...", "radius": 15, "distance_unit": "kilometer" }] } }`).
Sent alone it is forwarded unchanged. Use for advanced fields the structured
object does not expose (flexible_spec, excluded audiences, business places,
user_os, wireless_carrier).
Can be combined with `targeting`: rawTargeting is the BASE layer and the
built camelCase spec is merged on top, key by key (camelCase wins on
collision). The merge goes one level deep inside `geo_locations` and
`excluded_geo_locations` (built sub-keys win; raw-only sub-keys such as
`location_types` survive). Array values (`flexible_spec`, ...) are replaced
as a whole key, never element-merged.
When `rawTargeting` is present the `advantage_audience: 0` default that
Zernio normally applies is no longer emitted, so it cannot clobber a
`targeting_automation` sent in the raw spec. Meta requires
`targeting_automation` on ad set creation, so include it in the raw spec,
or send `targeting.advantage_audience` (0 or 1), which is merged over raw
as `targeting_automation`.
bidStrategy:
allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
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.
Meta bid strategy applied to the ad set. On TikTok, mapped to
`bid_type` / `bid_price` / `deep_bid_type` automatically.
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.
Bid cap in WHOLE currency units (USD: 5 = $5.00; JPY: 100 = ¥100). Required when
`bidStrategy` is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`. Backward-compat: providing
`bidAmount` without `bidStrategy` is treated as `LOWEST_COST_WITH_BID_CAP`.
roasAverageFloor:
type: number
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.
Minimum ROAS as a decimal multiplier (e.g. 2.0 = 2.0x ROAS). Required when
`bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`. Sent to Meta as
`bid_constraints.roas_average_floor` × 10000 (Meta uses fixed-point integers).
platformSpecificData:
description: |
Platform-specific options. The platform is derived from `accountId`;
sending options for a different platform returns a 400. LinkedIn
(campaign bidding and delivery controls) and Meta (the bid trio)
have options today.
**Meta**: `bidStrategy`, `bidAmount` and `roasAverageFloor` may be
sent here instead of at the root, which is the preferred home going forward.
Sending the bid fields in BOTH places returns a 400
(`mutually_exclusive_fields`).
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.
**Meta**: adds a top-level `call_to_action` to the post-reference creative.
This is what gives a `traffic` boost a clickable destination without
replacing the creative and losing the post's social proof. Ignored when
`leadGenFormId` is set, which supplies its own destination. Live-verified
against a Page-post creative.
**TikTok**: maps to `landing_page_url` on the Spark Ad creative
(`AdcreateCreatives.landing_page_url`); Spark Ads have no clickable
destination without it.
Ignored on LinkedIn / Pinterest / X / Google, which infer the destination
from the boosted post.
callToAction:
type: string
description: |
CTA button label. Non-messaging CTAs require `linkUrl`.
WHATSAPP_MESSAGE, MESSAGE_PAGE, and INSTAGRAM_MESSAGE do not
require a URL and reject linkUrl.
**Meta**: the CTA enum of POST /v1/ads/create plus
`VIEW_INSTAGRAM_PROFILE`, `WHATSAPP_MESSAGE`, `MESSAGE_PAGE`,
and `INSTAGRAM_MESSAGE`. VIEW_INSTAGRAM_PROFILE requires linkUrl;
the messaging CTAs select their destination automatically.
**TikTok**: pass-through to `call_to_action` on the Spark Ad creative; the
platform validates the value. See TikTok's "Enumeration - Call-to-Action".
sparkAuthCode:
type: string
description: |
TikTok-only. Spark Code (creator's `auth_code`) authorizing cross-creator
Spark Ads: the advertiser can boost a video owned by a DIFFERENT TikTok
account. Without this, boosts are limited to videos owned by the same
account running the ads (same-BC creators only). The creator generates the
code in their TikTok app's Promote settings and shares it with the
advertiser. Maps to `auth_code` on the creative entry of /v2/ad/create/.
promotedObject:
type: object
description: |
TikTok-only on this endpoint. The pixel a Website Conversion ad group
optimizes toward, so a Spark Ad built from an existing organic post can
optimize for a conversion instead of only engagement or traffic.
Required when `goal` is `conversions`, and BOTH fields are required:
TikTok refuses a conversion ad group with no pixel ("Please select a
pixel") and equally one that has a pixel but no event ("Select a pixel
event."), because the event is what the ad group optimizes toward. Ignored
on every other goal, since only a WEB_CONVERSIONS ad group accepts them.
Combine freely with `platformPostId` + `sparkAuthCode`: the pixel lives on
the ad group and the Spark item on the creative, so they never conflict.
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
(EU DSA, Article 26). Optional if the ad account has a default beneficiary:
set it once via `PATCH /v1/ads/accounts` or in Meta Ads Manager, and Meta
fills it in whenever the field is omitted.
dsaPayor:
type: string
maxLength: 100
description: |
Legal entity that pays for the ad. Can differ from `dsaBeneficiary`
(for example, an agency paying for a client's ads). Same rules as
`dsaBeneficiary`: required for EU targeting unless the ad account has
a default payor.
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,
defaults to the value derived from `goal`. Messaging boosts always
use CONVERSATIONS and reject another optimizationGoal. Otherwise the value must be compatible
with the objective Meta derives from `goal`, not with the objective used
by `POST /v1/ads/create` for the same `goal` name: boost maps `goal:
"engagement"` to objective `OUTCOME_AWARENESS`, which accepts
`REACH`, `IMPRESSIONS`, `AD_RECALL_LIFT`, or THRUPLAY-class values, and
rejects `POST_ENGAGEMENT` (that value is only valid under
`OUTCOME_ENGAGEMENT`, which create uses for the same goal name).
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:
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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.'
'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.
An identical boost request is already in progress (with or without
an Idempotency-Key). Wait for it to finish instead of retrying.
'422':
description: |
Platform ads connection required (TikTok Ads, X Ads), missing linked
account, or (for TikTok) the connected TikTok user is not authorized
as an Identity on the target advertiser. Returned with code
`ads_connection_required`; the message includes the actionable
"TikTok Ads Manager → Assets → Identity" remediation step.
Also returned as `idempotency_key_reused` when an Idempotency-Key
is reused with a different request body.
/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).
Google Performance Max: set `campaignType: "pmax"` and supply `assetGroup` with
text, images by role, business name and finalUrl. Creates a daily budget, PAUSED
campaign and asset group atomically. `validateOnly: true` validates the complete
request with Google without creating or persisting resources. Read assets with
`GET /v1/ads/campaigns/{campaignId}/asset-groups`. The logo is required; video is
optional via `assetGroup.youtubeVideoId`. Brand guidelines are disabled at creation.
All supplied asset links are validated together against Google's minimum asset requirements.
PMax rejects ACTIVE creation, portfolio bidding, bid caps, legacy creative fields
and attach shapes. Geo and language targeting are supported; omitted geo targets
all locations. PMax does not require top-level goal, headline, body or linkUrl.
Supported bidding: omitted or LOWEST_COST_WITHOUT_CAP for Maximize Conversions,
COST_CAP plus bidAmount for target CPA, LOWEST_COST_WITH_MIN_ROAS plus
roasAverageFloor for Maximize Conversion Value with target ROAS.
Other mutually-exclusive request shapes are selected by the body:
- Legacy single-creative shape (all platforms, the default).
- Meta-only multi-creative shape via the creatives array: one ad set with N ads sharing budget and targeting.
- 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.
Meta accepts `creativeFeatures` on the single and attach shapes and as defaults for
`creatives[]`; an item replaces the whole feature map. `promotion` is not supported on any
shape and any object is rejected with 400.
Reusing `existingCreativeId` uses the existing creative settings instead of new settings.
Requested settings are persisted for lists, exports, and default ad-detail reads.
Per-platform required fields, budget minimums, and video-ad rules are documented on each property below.
LinkedIn creates a Single Image or Single Video Ad backed by a Direct Sponsored Content "dark post" authored by a Company Page (see `organizationId`). Supported goals are engagement, traffic, awareness, and video_views (video ads use the `video` field; video_views requires a video), and traffic ads require `linkUrl`.
**Idempotency:** this endpoint is not idempotent at the platform level (a blind retry creates a second campaign/ad set/ad). Send an `Idempotency-Key` header to make retries safe: the first request with a given key creates the ad and we store the response; a retry with the same key replays that exact response (with `Idempotent-Replayed: true`) instead of creating duplicates. Reusing a key with a different body returns 422; a key whose first request is still in flight returns 409 (retry after a short backoff). Keys are scoped to your credential and expire after 24h.
security:
- bearerAuth: []
parameters:
- $ref: '#/components/parameters/IdempotencyKeyHeader'
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, name]
properties:
accountId: { type: string }
adAccountId: { type: string }
name: { type: string, maxLength: 255 }
campaignName: { type: string, maxLength: 255, description: "Meta only. Exact campaign name. Overrides the default `<name> - Campaign`." }
adSetName: { type: string, maxLength: 255, description: "Meta only. Exact ad set name. Overrides the default `<name> - Ad Set`. (For per-ad names on the multi-creative shape, set `name` on each `creatives[]` entry.)" }
adName: { type: string, maxLength: 255, description: "Meta only. Exact ad name (the single-creative ad object's name). Overrides the default, which is `name`. (For per-ad names on the multi-creative shape, set `name` on each `creatives[]` entry instead.)" }
tracking: { $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.
**Meta**
- `conversions`: OUTCOME_SALES. Requires `promotedObject.pixelId` and `promotedObject.customEventType` with a commerce event such as PURCHASE or START_TRIAL, or `promotedObject.customConversionId` to optimise against a Custom Conversion, or `customEventType: OTHER` + `customEventStr` to optimise against a pixel custom event.
- `lead_conversion`: OUTCOME_LEADS optimizing website pixel leads. Same pixel and event fields, but with a leads-class event such as LEAD, SUBMIT_APPLICATION, SCHEDULE or CONTACT (or `promotedObject.customConversionId` to optimise against a Custom Conversion instead). Meta gates conversion events by objective, so leads-class events are rejected under `conversions`.
- `lead_generation`: OUTCOME_LEADS with instant forms. Requires `leadGenFormId`. `promotedObject.pageId` is optional and auto-filled from the connected Page.
- `app_promotion`: requires `promotedObject.applicationId` and `promotedObject.objectStoreUrl`.
- `catalog_sales`: Advantage+ catalog ads, for example vehicle inventory. Requires `promotedObject.productSetId`, `promotedObject.pixelId` and `promotedObject.customEventType`. Builds a catalog TEMPLATE creative from the copy fields, which may carry template tags like {{product.name}} or {{vehicle.make}}. No imageUrl or video is sent; Meta renders the visuals per catalog item. Discover catalogs via GET /v1/ads/catalogs and product sets via GET /v1/ads/catalogs/{catalogId}/product-sets. Single shape only, no creatives[], adSetId, dynamicCreative or placementAssets.
- `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).
**TikTok**
- `conversions`: website-conversion ad group. Requires `promotedObject.pixelId`, your TikTok Pixel ID. Accepts an optional `promotedObject.customEventType` with a TikTok optimization_event code your pixel tracks (newer pixels use e.g. SHOPPING for purchase events; legacy pixels use ON_WEB_ORDER, INITIATE_ORDER, ON_WEB_REGISTER or FORM). To inherit pixel and event from an existing ad group, pass `adSetId` instead.
**LinkedIn**
- `engagement`, `traffic`, `awareness` and `video_views` create standalone Direct Sponsored Content ads. `traffic` requires `linkUrl`; `video_views` requires `video`.
- `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}`.
- `job_applicants` requires a `platformSpecificData.jobs` creative.
- For `conversions` on LinkedIn, or to promote an existing post, use POST /v1/ads/boost.
**OpenAI Ads**
- Only `traffic`, `awareness`, and `conversions` are supported (other goals return 400). Maps to OpenAI's `bidding_type` (clicks, impressions, conversions respectively). `conversions` requires an active conversion event setting on the account; create a tracking tag with `defaultEventType` via the tracking-tags API (`POST /v1/accounts/{accountId}/tracking-tags`), or configure a conversion event in OpenAI Ads Manager, or the request returns 422.
optimizationGoal: { type: string, description: "Meta only. Explicit ad-set `optimization_goal` (e.g. `LANDING_PAGE_VIEWS`, `LINK_CLICKS`, `REACH`, `IMPRESSIONS`, `OFFSITE_CONVERSIONS`, `THRUPLAY`, `LEAD_GENERATION`). Overrides the default derived from `goal` (e.g. `traffic` defaults to `LINK_CLICKS`). Forwarded verbatim to Meta, which validates compatibility with the campaign objective and rejects incompatible combinations." }
billingEvent: { type: string, description: "Meta only. Explicit ad-set `billing_event`. Defaults to `IMPRESSIONS`. Forwarded verbatim to Meta, which validates compatibility with the optimization goal." }
buyingType: { type: string, enum: [AUCTION, RESERVED], 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:
- `adset` (default): ABO (Ad-set Budget Optimization). The budget is set on the
ad set. This is the back-compatible behaviour; omit this field to keep it.
- `campaign`: CBO (Campaign Budget Optimization / Advantage Campaign Budget). The
budget AND `bidStrategy` are set on the CAMPAIGN, and Meta distributes spend
across ad sets automatically.
The returned ad stores the applied `budgetLevel` and budget in `campaignBudget`
for CBO or `adSetBudget` for ABO. Edit CBO budgets with
`PUT /v1/ads/campaigns/{campaignId}` and ABO budgets with
`PUT /v1/ads/ad-sets/{adSetId}`.
Meta requires the budget at exactly one level, never both. Non-Meta platforms ignore
this field. Ignored on the attach shape (`adSetId`), which inherits the existing budget.
currency: { type: string, 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
variations and Meta optimises delivery across them, WITHOUT enabling full Dynamic Creative
(`dynamicCreative`). Uses `optimization_type: DEGREES_OF_FREEDOM` on the asset feed, so
multiple ads per ad set are allowed (unlike `dynamicCreative` which is limited to one).
Requires `imageUrl` or `video`, `linkUrl`, and `callToAction`. When set, the top-level
`body` field is used as the `object_story_spec.link_data.message` (the preview text) and
`headlines` must also be present. On a video creative the copy lands in
`video_data.message` / `video_data.title` instead of `link_data`. Mutually exclusive
with `dynamicCreative`, `placementAssets`, `carouselCards`, and `creatives[]`. For placement-specific copy, use the singular `placementAssets.rules[].body` and `headline` fields instead.
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`.
The top-level `headline` field is used as the `object_story_spec.link_data.name`
(`video_data.title` on a video creative).
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:
creates 1 campaign + 1 ad set + N ads (one per entry here).
Top-level `headline` / `body` / `imageUrl` / `linkUrl` /
`callToAction` are ignored in this mode. Mutually exclusive with `adSetId`.
items:
type: object
required: [headline, body, linkUrl, callToAction]
description: "Each creative must supply EXACTLY ONE of `imageUrl` (image creative) or `video` (video creative)."
properties:
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
one new ad to this existing ad set without creating a new
campaign. Budget, targeting, goal, schedule, AND bid strategy
are inherited from the ad set on Meta, and passing `bidStrategy`
in attach mode returns 400. To change an existing ad set's
bid, use `PUT /v1/ads/ad-sets/{adSetId}`. Mutually exclusive
with `creatives[]`. `dynamicCreative` returns 400 in attach mode: create
a new dynamic ad set by omitting `adSetId` instead.
The attached ad takes the full single-creative surface:
`headline`/`body`/`description`/`callToAction` plus either
`imageUrl`/`video` OR `placementAssets` (its own per-placement
Feed/Story assets) OR `translations`/`defaultLocale` (its own
per-locale asset feed, Meta only), and `leadGenFormId` when
the target is a lead ad set (the parent must be ON_AD, true for ad sets
created via goal `lead_generation`; Meta rejects a formless ad
there, so pass the form on EVERY attached ad). This is the way
to build N full ads sharing one ad set: create the first ad
via the normal shape, then attach the rest one call each.
Supported on Meta (facebook, instagram), Google Ads, TikTok,
and LinkedIn. On TikTok the `adSetId` is the ad group ID; the
new ad inherits the ad group's bid + budget + targeting.
On LinkedIn the `adSetId` is the LinkedIn Campaign ID
(numeric); we attach a new Creative to that Campaign, so
the Campaign's `platformSpecificData` bidding, targeting,
budget and schedule are inherited (passing those fields
returns 400).
On Google Ads the `adSetId` is the AD GROUP id. `goal` is
still REQUIRED even though budget and targeting are
inherited from the ad group. Send `campaignType: "search"`
to attach into a Search ad group, including one created by
`POST /v1/ads/ad-sets` (always SEARCH_STANDARD): without it
the request is treated as Display and requires
`images.landscape` + `images.square` + `businessName`, and
the resulting display creative does not match a Search ad
group.
`budgetAmount`/`budgetType` and bidding fields
(`bidStrategy`, `bidAmount`, `portfolioBidStrategyId`)
return 400 on this shape; the ad group already owns them.
existingCampaignId:
type: string
description: |
Meta, Google Ads, and LinkedIn. On Meta: add the new ad
set under this EXISTING campaign instead of creating a new
one (multi-ad-set audience testing). The new ad set's
budget is matched to the campaign's mode automatically:
for a CBO campaign (campaign-level budget) omit
`budgetAmount`/`budgetType`, since the campaign owns the
budget; for an ABO campaign pass them (they go on the new
ad set). On LinkedIn: create a new Campaign (and its
Creative) under this EXISTING CampaignGroup. On Google
Ads: create a new ad group under this EXISTING campaign;
the new ad group inherits the campaign's budget, so omit
`budgetAmount`/`budgetType` (and any bidding field), or
the request returns 400. On failure only the entities we
authored are cleaned up; the pre-existing parent is left
untouched and is never (re)activated. Mutually exclusive
with `adSetId` and `creatives[]`.
existingCreativeId:
type: string
description: |
Meta only. Reuse an EXISTING ad creative by id instead of
building a new one from the copy/media fields (which are then
ignored). Combine with `existingCampaignId` to build a
multi-ad-set campaign that shares one creative. Mutually
exclusive with `creatives[]`, `dynamicCreative`, and
`placementAssets`. The creative id used is returned as
`creativeId` on the create response.
businessName: { type: string, maxLength: 25, description: "Google Display only" }
boardId: { type: string, description: "Pinterest only. Board ID (auto-creates if not provided)." }
organizationId: { type: string, description: "LinkedIn only. The Company Page that authors the Direct Sponsored Content (\"dark\") post backing the ad. Accepts a numeric organization ID or a full `urn:li:organization:N` URN. Required unless the resolved `accountId` is a connected LinkedIn Company-Page account (defaults to that page) or the LinkedIn ad account is org-owned (defaults to the account's owning organization). The authenticated member must be an ADMINISTRATOR or DIRECT_SPONSORED_CONTENT_POSTER of this page (and the page must be associated with the ad account), or LinkedIn returns 403. Ignored by every other platform." }
targeting:
description: |
Nested targeting object, the same TargetingSpec shape as `POST /v1/ads/boost`,
`POST /v1/ads/targeting/reach-estimate`, and `saved_targeting` audiences. Merged
UNDER the flat inline targeting fields below: `savedTargetingId` < `targeting` <
flat fields (a flat field present on the body replaces the nested value entirely).
Both forms are equivalent; use whichever your integration already builds.
allOf:
- $ref: '#/components/schemas/TargetingSpec'
countries: { type: array, items: { type: string }, description: "ISO 3166-1 alpha-2 country codes (e.g. ['NL']). Defaults to ['US'] when no other geo targeting (flat or nested `targeting`) is provided. (LinkedIn and OpenAI Ads currently honour country-level targeting only; any other targeting field returns 400 for OpenAI Ads.)" }
countryGroups:
type: array
description: |
Meta only. Continents and trade blocs (`geo_locations.country_groups`),
for targeting a whole region without listing its countries. Combines with
`countries` rather than replacing it. Discoverable via
`GET /v1/ads/targeting/search?dimension=geo&geoType=country_group`.
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).
On Meta, cannot overlap with the same country in `countries` (Meta returns a "locations overlap" error). Either drop the country or scope it to a different country. On TikTok, keys are numeric location ids and can be sent without `countries`.
items:
type: object
required: [key]
properties:
key: { type: string, description: "Meta city ID, from /v1/ads/targeting/search results." }
radius: { type: number, description: "Optional radius around the city. Must be set together with distance_unit. Meta enforces a minimum city radius (~17 km / 10 mi); smaller values resolve to a 0-size audience and the ad fails at launch. For a tighter catchment use customLocations (lat/lng)." }
distance_unit: { type: string, enum: [mile, kilometer], description: "Unit for radius. Required if radius is set." }
regions:
type: array
description: |
Region-level (state/province) geo targeting (Meta and TikTok). Each region is targeted by the platform's opaque `key` (the region ID) which can be looked up via `GET /v1/ads/targeting/search?dimension=geo&q=<name>&countryCode=<ISO>`.
items:
type: object
required: [key]
properties:
key: { type: string, description: "Platform region ID, from /v1/ads/targeting/search results." }
ageMin: { type: integer, minimum: 13, maximum: 65 }
ageMax: { type: integer, minimum: 13, maximum: 65 }
interests:
type: array
description: "Interest objects from /v1/ads/interests. Each must include id and name."
items:
type: object
required: [id, name]
properties:
id: { type: string }
name: { type: string }
zips:
type: array
description: "Postal/ZIP geo targeting. `key` is the platform's postal location ID from /v1/ads/targeting/search?dimension=geo&geoType=zip. Supported on Meta, Google, TikTok, Pinterest, X."
items:
type: object
required: [key]
properties:
key: { type: string }
name: { type: string }
metros:
type: array
description: "DMA / metro-area geo targeting (Meta and TikTok). `key` is the platform's metro ID from /v1/ads/targeting/search?dimension=geo&geoType=metro (TikTok metros appear as type `metro`, e.g. the New York DMA)."
items:
type: object
required: [key]
properties:
key: { type: string }
name: { type: string }
customLocations:
type: array
description: "Point-radius (lat/lng) geo targeting. Meta only (custom_locations). Rejected on platforms without radius support."
items:
type: object
required: [latitude, longitude, radius, distanceUnit]
properties:
latitude: { type: number, minimum: -90, maximum: 90 }
longitude: { type: number, minimum: -180, maximum: 180 }
radius: { type: number }
distanceUnit: { type: string, enum: [mile, kilometer] }
name: { type: string }
address: { type: string }
behaviors:
type: array
description: "Behaviour entities from /v1/ads/targeting/search?dimension=behavior. Supported on Meta and TikTok. Each must include id."
items:
type: object
required: [id]
properties:
id: { type: string }
name: { type: string }
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
`top_10`; rejected on LinkedIn, X, and Pinterest. On Meta, income targeting is incompatible
with housing/employment/credit `specialAdCategories`.
languages: { type: array, items: { type: string }, description: "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,
recommended for most cases, since Meta optimises delivery across all eligible surfaces).
When set, restricts delivery to the chosen surfaces, mapped onto the ad set's
`targeting.{publisher_platforms, facebook_positions, instagram_positions,
messenger_positions, audience_network_positions, threads_positions,
whatsapp_positions, device_platforms}`. Enum membership is validated here; Meta
additionally enforces co-selection rules (e.g. some positions require their parent
publisher platform) and returns an actionable error which we surface. Non-Meta
platforms reject this field.
properties:
publisherPlatforms:
type: array
items: { type: string, enum: [facebook, instagram, threads, messenger, audience_network, whatsapp] }
description: "Top-level platforms to deliver on. A position field below is only honoured when its parent platform is included here."
facebookPositions:
type: array
items: { type: string, enum: [feed, right_hand_column, marketplace, video_feeds, story, search, instream_video, facebook_reels, facebook_reels_overlay, profile_feed, notification] }
instagramPositions:
type: array
items: { type: string, enum: [stream, story, explore, explore_home, reels, profile_feed, ig_search, profile_reels] }
messengerPositions:
type: array
items: { type: string, enum: [messenger_home, sponsored_messages, story] }
audienceNetworkPositions:
type: array
items: { type: string, enum: [classic, rewarded_video] }
threadsPositions:
type: array
items: { type: string, enum: [threads_stream] }
whatsappPositions:
type: array
items: { type: string, enum: [status] }
devicePlatforms:
type: array
items: { type: string, enum: [mobile, desktop] }
description: "Restrict by device. Omit to deliver on both mobile and desktop."
savedTargetingId:
type: string
description: |
ID of a `saved_targeting` audience (created via POST /v1/ads/audiences). When set, its stored
TargetingSpec is expanded as the base targeting; inline fields on this body merge on top. Lets you
reuse a named targeting preset without re-sending every field.
rawTargeting:
type: object
additionalProperties: true
description: |
Meta only. A raw Meta-native targeting spec (snake_case: `geo_locations`, `age_min`,
`excluded_custom_audiences`, `flexible_spec`, `targeting_automation`, `user_os`,
`wireless_carrier`, business places, etc.), exactly the shape `GET /v1/ads/{adId}` returns for
external ads. Sent alone it reaches the ad set VERBATIM (the clone-a-campaign's-targeting-exactly
path). Meta validates and surfaces any errors.
Can be combined with the camelCase targeting fields (countries/regions/cities/interests/ageMin/...,
`targeting`, `savedTargetingId`, `audienceId`): rawTargeting is the BASE layer and the built
camelCase spec is merged on top, key by key, with the camelCase side winning on collision (the
camelCase precedence chain stays `savedTargetingId` < `targeting` < flat fields). The merge goes
one level deep inside `geo_locations` and `excluded_geo_locations`: built sub-keys win, raw-only
sub-keys such as `location_types` survive alongside built `countries`. Array values
(`flexible_spec`, ...) are replaced as a WHOLE key when the camelCase spec builds them, never
element-merged. When rawTargeting is present the defaults the camelCase builder normally injects
(US geo, `targeting_automation.advantage_audience: 0`) are suppressed, so raw's values are not
clobbered. Include `targeting_automation` in the raw spec (or send `advantageAudience`) as Meta
requires it on create. If cloning an EU campaign, also pass `dsaBeneficiary` / `dsaPayor` (those
are separate fields, not part of targeting).
specialAdCategories:
type: array
description: |
Meta only. Declares the ad's special category, required for housing, employment, credit, or
political/social-issue ads (Meta enforces restricted targeting for these). Note: setting a special
category disables income/zip targeting on Meta.
items: { type: string, enum: [HOUSING, EMPLOYMENT, CREDIT, FINANCIAL_PRODUCTS_SERVICES, ISSUES_ELECTIONS_POLITICS, ONLINE_GAMBLING_AND_GAMING] }
specialAdCategoryCountry:
type: array
description: |
Meta (metaads) only. 2-letter ISO country codes the special ad category applies to. Requires
specialAdCategories to be set (400 otherwise). Ignored when joining an existing campaign via
existingCampaignId (the existing campaign's category/country already governs it).
items: { type: string }
regionalRegulatedCategories:
type: array
description: |
Meta only. Regional regulation categories required when the ad set targets certain countries.
Known values: BRAZIL_REGULATION, SINGAPORE_UNIVERSAL, TAIWAN_UNIVERSAL, THAILAND_UNIVERSAL,
AUSTRALIA_FINSERV, INDIA_FINSERV, TAIWAN_FINSERV. Meta rejects the ad set without this when
the targeting geo includes the corresponding country.
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.
endDate: { type: string, format: date-time, description: Required for lifetime budgets }
startDate:
type: string
format: date-time
description: |
Meta only. Ad-set start time (ISO 8601, e.g. "2026-06-10T09:00:00Z"), mapped to the
ad set's `start_time`. When omitted the ad starts delivering immediately. For lifetime
budgets Meta also requires `endDate`. (Same `schedule.startDate` semantics already
available on `POST /v1/ads/boost`.)
instagramAccountId:
type: string
description: |
Meta only. Override the Instagram account the ad is delivered as. Pass an Instagram
Business Account ID (e.g. 17841...), mapped to the creative's `instagram_user_id`.
When omitted we use the Instagram actor Meta already runs the Page's other ads as,
falling back to the Page's page-backed Instagram account. Useful when a Page has more
than one eligible IG account.
dynamicCreative:
type: object
description: |
Meta only. Dynamic Creative: supply a POOL of assets and Meta auto-combines and
optimises them into the best-performing variations within a single ad (mapped to the
creative's `asset_feed_spec`). When set, the top-level single-creative fields
(`imageUrl`, `headline`, `body`, `linkUrl`, `callToAction`) are ignored. Mutually
exclusive with the `creatives[]` multi-creative shape. Exactly ONE of `imageUrls` /
`videoUrls` is required (Meta allows one ad format per asset feed; sending both →
400). Limits remain 10 images or videos and 5 bodies, titles or descriptions.
The ad set is created with `is_dynamic_creative: true`. Combining this field
with `adSetId` returns 400: omit `adSetId` to create a new dynamic ad set.
Multiple headlines go in `titles`; multiple primary texts go in `bodies`.
properties:
imageUrls:
type: array
minItems: 1
maxItems: 10
items: { type: string, format: uri }
description: "Pool of image URLs (1-10). Uploaded to the ad account and referenced by hash in the asset feed. Mutually exclusive with `videoUrls`."
videoUrls:
type: array
minItems: 1
maxItems: 10
items: { type: string, format: uri }
description: "Pool of video URLs (1-10). Uploaded to the ad account and referenced by video id in the asset feed. No thumbnails are needed: Meta auto-generates a poster per video. Mutually exclusive with `imageUrls`; `adFormat` defaults to SINGLE_VIDEO."
bodies:
type: array
maxItems: 5
items: { type: string }
description: "Primary-text variations (the body copy)."
titles:
type: array
maxItems: 5
items: { type: string }
description: "Headline variations."
descriptions:
type: array
maxItems: 5
items: { type: string }
description: "Description (link caption) variations."
linkUrls:
type: array
items: { type: string, format: uri }
description: "Destination URL variations. At least one is required unless `goal` is `lead_generation`."
callToActionTypes:
type: array
items: { type: string, enum: [LEARN_MORE, SHOP_NOW, SIGN_UP, BOOK_TRAVEL, CONTACT_US, DOWNLOAD, GET_OFFER, GET_QUOTE, SUBSCRIBE, WATCH_MORE, ADD_TO_CART, APPLY_NOW, BOOK_NOW, BUY_TICKETS, DONATE, DONATE_NOW, GET_DIRECTIONS, GET_SHOWTIMES, LISTEN_NOW, ORDER_NOW, PLAY_GAME, REQUEST_TIME, SEE_MENU, START_ORDER, INSTALL_MOBILE_APP, USE_APP, REGISTER, JOIN, ATTEND, REQUEST_DEMO, VIEW_QUOTE, APPLY, SEE_MORE, BUY_NOW] }
description: "CTA-button variations. Required."
adFormat:
type: string
enum: [SINGLE_IMAGE, CAROUSEL_IMAGE, SINGLE_VIDEO]
description: "Asset-feed ad format. Must match the pool: SINGLE_IMAGE / CAROUSEL_IMAGE require `imageUrls`, SINGLE_VIDEO requires `videoUrls` (400 otherwise). Defaults to SINGLE_IMAGE with `imageUrls`, SINGLE_VIDEO with `videoUrls`."
carouselCards:
type: array
minItems: 2
maxItems: 10
description: |
Meta only. Hand-built carousel: 2-10 authored cards in DETERMINISTIC order, mapped to
the creative's `link_data.child_attachments`. Unlike `dynamicCreative`,
you control the card order and per-card copy/link. Requires top-level `body`
and `callToAction`; `linkUrl` is also required UNLESS `leadGenFormId` is set. Those
become the ad's own Destination and button (`link_data.link` / `link_data.call_to_action`),
and double as the per-card fallback when a card omits its own.
Mutually exclusive with `imageUrl`/`video`, `creatives[]`, `dynamicCreative`,
`placementAssets`, `existingCreativeId`, `adSetId` and goal
`catalog_sales`. Combines with `leadGenFormId` to build a carousel Instant-Form
lead ad: `linkUrl` and per-card `linkUrl` become OPTIONAL and, when sent, are
forwarded as the real card and top-level destinations; when omitted, the
destination falls back to Meta's lead-form link.
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
per-locale copy and, optionally, per-locale media: the "Languages" toggle in Ads
Manager. Keeps social proof (likes/comments/shares) on a SINGLE post instead of
splitting it across one ad per language.
The ad's top-level copy is the DEFAULT shown to every locale you do NOT list,
and it counts as one of the language variants.
IMPORTANT, and the opposite of what you might expect: text does NOT inherit.
Every entry must carry its own `headline`, `body` AND `description`, and all of
them must be DISTINCT from each other and from the ad's top-level copy. Meta
deduplicates identical strings inside the asset feed, so two locales sharing a
string collapse into one asset and the create fails with a misleading "Too few
... texts provided in asset creation" (subcode 1885817) that names a field which
is actually present. We validate this before calling Meta and return a 400
naming the offending locale and field. `description` is therefore effectively
required on the ad whenever `translations` is present, even though it is
optional otherwise.
Do NOT list `defaultLocale` inside `translations`: Meta rejects the duplicate
with "The language asset feed includes an unsupported targeting field"
(subcode 1885985).
Media DOES inherit and is uploaded once when shared, and `linkUrl` inherits
too: each locale may name its own landing page and unlisted locales fall back
to the ad's top-level `linkUrl`. Meta enforces
Dynamic Creative image dimensions on language feeds, so an `imageUrl` that
works on a normal ad may be rejected with "The following images have invalid
dimensions for Dynamic Creative" (subcode 1885558). Video is not affected.
Mutually exclusive with `dynamicCreative`, `placementAssets`, `carouselCards`,
`existingCreativeId` and `creatives[]`. Meta allows one `asset_feed_spec` shape per creative.
items:
type: object
required: [locale, headline, body, description]
properties:
locale: { type: string, description: "Language code, resolved to Meta's numeric locale id. Bare codes target the '(All)' umbrella (`es` = every Spanish variant); region-qualified codes target the variant (`pt_BR`, `en_GB`)." }
headline: { type: string, maxLength: 255, description: "Headline for this language. REQUIRED, and must differ from every other locale and from the ad's top-level headline." }
body: { type: string, description: "Primary text for this language. REQUIRED, and must differ from every other locale and from the ad's top-level body." }
description: { type: string, maxLength: 255, description: "Link description for this language. REQUIRED, and must differ from every other locale and from the ad's top-level description." }
linkUrl: { type: string, format: uri, description: "Destination URL for this language. Inherits the ad's top-level `linkUrl` when omitted, and requires it to be present (400 otherwise): the top-level URL is the destination for every locale you did not override. Unlike text, identical URLs across locales are fine (they share one asset)." }
imageUrl: { type: string, format: uri, description: "Image for this language. Inherits the ad's `imageUrl` when omitted. The feed is all-image OR all-video." }
videoUrl: { type: string, format: uri, description: "Video for this language. Inherits the ad's `video.url` when omitted. The feed is all-image OR all-video." }
thumbnailUrl: { type: string, format: uri, description: "Poster frame for this language's video." }
placementAssets:
type: object
description: |
Meta only. Placement asset customization: pin a SPECIFIC asset (image OR video) to
each placement group on a SINGLE ad (e.g. a 9:16 on Stories/Reels and a 4:5 on Feed).
The same thing Meta Ads Manager produces with "different creative per placement",
mapped to the creative's `asset_feed_spec` + `asset_customization_rules`. Deterministic
pinning, NOT the auto-optimizing pool of `dynamicCreative` (mutually exclusive). Works
on the legacy single shape AND the attach shape (`adSetId` + placementAssets adds one
placement-customized ad to an existing ad set, the way to build N per-placement ads
sharing one ad set: create the first normally, attach the rest). Cannot be combined
with `creatives[]` or top-level `bodies`/`headlines`/`descriptions` arrays. Each rule
can override `headline`, `body` and `description` with one string per field. Omitted
fields and unmatched placements use the top-level copy; `linkUrl` and `callToAction`
remain shared. Zernio emits labelled text with `optimization_type: PLACEMENT`.
Multiple text options rotating within a placement are not supported by this input. Each rule's `placements` accepts the same fields as the top-level
`placements` object; Meta enforces co-selection rules and returns an actionable error.
Meta controls text rendering by placement and format. Validation accepts these fields
but does not prove that every field appears in delivery. Preview the ad; put copy that
must always be visible into the image or video itself.
`validateOnly: true` supports all-image placementAssets without uploading or creating
anything. Video placement validation remains unsupported because it requires uploads.
A block is all-image OR all-video, never mixed (Meta's asset_feed_spec carries one ad
format). Image mode: `defaultImageUrl` + `rules[].imageUrl`. Video mode:
`defaultVideoUrl` + `rules[].videoUrl` (optional `thumbnailUrl`/`defaultThumbnailUrl`
posters; Meta auto-generates when omitted). Exactly one catch-all default is required.
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.
Each entry becomes an Asset (with sitelink_asset + Asset.final_urls) plus a
CampaignAsset link (field_type SITELINK). Approval is async: Google reviews
assets after creation; poll asset.policy_summary later to read the verdict.
Google requires at least two sitelinks to surface them on an ad; four or more
is Google's own recommendation for maximum visibility. The response's
creative.sitelinks[] echoes each input plus its Google resourceName.
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
non-clickable annotations under the ad, e.g. "Free shipping", "24/7 support".
Each becomes one Asset (`callout_asset`) plus a CampaignAsset link with
field_type CALLOUT. Response's creative.callouts[] echoes each input plus
its Google resourceName.
structuredSnippets:
type: array
minItems: 1
maxItems: 20
description: |
Google Search only. Structured snippets: one header from Google's
predefined list plus 3-10 values (max 25 chars each). Each becomes one
Asset (`structured_snippet_asset`) plus a CampaignAsset link with
field_type STRUCTURED_SNIPPET.
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
ad-set `attribution_spec`. Only honored for conversion goals (`conversions`,
`lead_generation`, `app_promotion`); ignored for awareness/traffic/engagement.
Omit to use Meta's default (`7-day click` + `1-day view`). Meta enforces the
valid combinations: `VIEW_THROUGH` only allows `windowDays: 1` (7d/28d view
windows were removed Jan 2026); `ENGAGED_VIDEO_VIEW` only `1` and only alongside
`VIEW_THROUGH: 1`; `CLICK_THROUGH: 28` only on certain objectives. Invalid combos
surface as a Meta 400.
Example: `[{ "eventType": "CLICK_THROUGH", "windowDays": 7 }, { "eventType": "VIEW_THROUGH", "windowDays": 1 }]`
gender: { type: string, enum: [all, male, female], default: all, description: "Restrict the audience by gender. 'male' targets men only, 'female' targets women only, 'all' (default) targets everyone. Applied on Meta, TikTok and Pinterest. Ignored on Google, LinkedIn and X." }
bidStrategy:
allOf: [{ $ref: '#/components/schemas/BidStrategy' }]
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.
Meta bid strategy applied to the ad set.
OpenAI 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.
Google (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.
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.
Bid cap in WHOLE currency units (USD: 5 = $5.00; JPY: 100 = ¥100). Required when
`bidStrategy` is `LOWEST_COST_WITH_BID_CAP` or `COST_CAP`. Meta only: sending
`bidAmount` WITHOUT `bidStrategy` requires `existingCampaignId` (400 otherwise),
and sets the new ad set's cap under the joined campaign's COST_CAP /
LOWEST_COST_WITH_BID_CAP parent. The strategy itself is inherited from the
campaign. Restating bidStrategy here is accepted but has no effect on the ad set.
Rejected with 400 in `adSetId` attach mode: that shape inherits its cap from
the platform. Use `PUT /v1/ads/ad-sets/{adSetId}` there instead.
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.
Minimum ROAS as a decimal multiplier (e.g. 2.0 = 2.0x ROAS). Required when
`bidStrategy` is `LOWEST_COST_WITH_MIN_ROAS`. Sending it without `bidStrategy`
is a 400. Sent to Meta as
`bid_constraints.roas_average_floor` × 10000. Known gap: a CBO campaign's
ROAS floor lives on the campaign only (set via `POST /v1/ads/campaigns`);
there is no supported way to set it while joining a CBO campaign here.
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
to attach to the new ad set, from `/v1/ads/value-rule-sets`. Attachment is
driven by this id, so `valueRulesApplied` is optional alongside it.
Rejected with 400 in `adSetId` attach mode: that shape inherits the existing
ad set's attachment, so the field would be silently ignored. Use
`PUT /v1/ads/ad-sets/{adSetId}` there instead.
Ignored (stripped before the ad-set create) when `buyingType` is `RESERVED`:
value rules only apply to auction ad sets on `LOWEST_COST_WITHOUT_CAP` or
`COST_CAP`, and a Reach & Frequency reservation has no auction bid strategy.
Read back with `GET /v1/ads/ad-sets/{adSetId}?fields=value_rule_set_id`; the
attachment is not mirrored onto Zernio's ad documents.
valueRulesApplied:
type: boolean
description: |
Meta only (facebook, instagram; other platforms return 400). Optional when
attaching, and requires `valueRuleSetId`. `false` is REJECTED here with 400:
a newly created ad set has nothing to detach, so detaching lives on
`PUT /v1/ads/ad-sets/{adSetId}`.
platformSpecificData:
description: |
Platform-specific options. The platform is derived from `accountId`;
sending options for a different platform returns a 400. LinkedIn
(campaign bidding and delivery controls) and Meta (the bid trio)
have options today.
**Meta**: `bidStrategy`, `bidAmount` and `roasAverageFloor` may be
sent here instead of at the root, which is the preferred home going forward.
Sending the bid fields in BOTH places returns a 400
(`mutually_exclusive_fields`), and sending any of them in
`adSetId` attach mode is a 400 too (the ad set already has its bid).
`dailyMinSpendTarget` / `lifetimeMinSpendTarget` set the new ad set's
minimum spend and live here only; they are rejected in `adSetId` attach
mode as well.
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
(EU DSA, Article 26). Optional if the ad account has a default beneficiary:
set it once via `PATCH /v1/ads/accounts` or in Meta Ads Manager, and Meta
fills it in whenever the field is omitted.
dsaPayor:
type: string
maxLength: 100
description: |
Legal entity that pays for the ad. Can differ from `dsaBeneficiary`
(for example, an agency paying for a client's ads). Same rules as
`dsaBeneficiary`: required for EU targeting unless the ad account has
a default payor.
brandIdentity:
type: object
description: |
TikTok only. Synthetic Brand Identity used when the ad
attributes to a CUSTOMIZED_USER (instead of a real TT_USER
@username). Required on the FIRST CUSTOMIZED_USER ad on a
`tiktokads` SocialAccount with no cached identity; omit on
subsequent ads (the identity is cached on the account after
first creation). Non-TikTok platforms ignore this field.
Alternative: configure once via `PATCH /v1/connect/tiktok-ads`,
then create ads without this field.
required: [displayName, imageUrl]
properties:
displayName:
type: string
minLength: 1
maxLength: 40
description: Brand name shown above the ad on TikTok.
imageUrl:
type: string
format: uri
description: Public URL of a square brand image (≥98×98 px, JPG/PNG). Used as the brand avatar on the ad.
identityType:
type: string
enum: [TT_USER, CUSTOMIZED_USER]
description: |
TikTok only. Forces the identity attribution on the ad:
- `TT_USER`: the posting account's open_id (real @username
branding). Requires a connected TikTok posting account
on the same profile.
- `CUSTOMIZED_USER`: synthetic Brand Identity (display
name + avatar). Requires a configured Brand Identity
(cached on the `tiktokads` SocialAccount via
`PATCH /v1/connect/tiktok-ads`) or an inline
`brandIdentity` to create one on the fly.
When omitted, defaults to `TT_USER` if a posting account is
connected on this profile, else `CUSTOMIZED_USER`. Spark
Ads (`POST /v1/ads/boost`) always use `TT_USER` regardless
of this field, because TikTok requires the original organic
post's author identity for Spark.
smartPlus:
type: boolean
description: |
TikTok only. Creates the ad as a TikTok Upgraded Smart+
campaign: TikTok automates targeting, bidding and delivery. Supports goals
`conversions` (Smart+ Web Conversions), `lead_generation` (Smart+ Lead
Generation with a website form on `linkUrl`; TikTok Instant Forms not supported)
and `app_promotion` (Smart+ App installs; the ad's destination is the app store,
so `linkUrl` is not used). The web goals require `promotedObject.pixelId` AND
`promotedObject.customEventType`; `app_promotion` requires
`promotedObject.applicationId` instead.
Targeting works like on any TikTok ad (defaults to `countries: ["US"]` when
omitted); TikTok automates delivery within it.
The budget lives on the Smart+ campaign (Campaign Budget Optimization); a `lifetime`
budget additionally requires `endDate`. Cannot be combined with `adSetId`.
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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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.'
'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,
or a shape the adapter does not implement. Carries code `feature_not_available`.
'502':
description: |
The platform rejected the request, or failed to produce media the ad
needs (e.g. Meta generated no poster for an uploaded video when no
`video.thumbnailUrl` was supplied). Inspect `platformError` for the
upstream payload. Failures we raise carry a `reason`; a payload
forwarded verbatim from Meta may not. On the `creatives[]` shape a
missing poster also carries `creativeIndex` and `videoUrl` to
identify the entry. An upstream 4xx status is forwarded instead
of 502.
'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.
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. }
'503': { $ref: '#/components/responses/TemporarilyUnavailable' }
'502': { $ref: '#/components/responses/UpstreamFailure' }
/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.
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.
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.
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.
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.
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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Leads for the form.
content:
application/json:
schema:
type: object
properties:
status: { type: string, example: success }
leads:
type: array
items:
type: object
properties:
id: { type: string }
createdTime: { type: [string, "null"] }
adId: { type: [string, "null"] }
formId: { type: string }
fields: { type: object, additionalProperties: { type: string } }
fieldData: { type: array, items: { type: object } }
pagination: { type: object, properties: { hasMore: { type: boolean }, cursor: { type: [string, "null"] } } }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/ads/lead-forms/{formId}/test-leads:
post:
x-resource-group: "contacts"
operationId: createTestLead
tags: ["Lead Gen"]
x-platforms: ["meta"]
summary: Create a test lead
description: >
Submits a test lead against the form (POST /{form-id}/test_leads) to
exercise retrieval without waiting for real ad impressions. Meta allows
one test lead per form at a time.
parameters:
- { name: formId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, fieldData]
properties:
accountId: { type: string }
fieldData:
type: array
minItems: 1
items:
type: object
required: [name, values]
properties:
name: { type: string }
values: { type: array, items: { type: string }, minItems: 1 }
responses:
'200':
description: Test lead created.
content:
application/json:
schema: { type: object, properties: { status: { type: string }, testLead: { type: object, properties: { id: { type: string } } } } }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
/v1/ads/images:
post:
x-resource-group: "ads"
operationId: uploadAdImage
tags: ["Ad Creatives"]
x-platforms: ["meta"]
summary: Upload an ad image from base64
description: |-
Uploads raw image bytes to the Meta ad account's image library, for callers whose
creatives aren't hosted at a public URL. Returns the image `hash` (Meta's identifier for
the asset) and the Meta-hosted `url`, which can be used directly as `imageUrl` on the
create endpoints. Max 30 MB decoded.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, imageBase64]
properties:
accountId: { type: string, description: "Zernio SocialAccount id (posting or ads variant); 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'201':
description: Image uploaded
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
image:
type: object
properties:
hash: { type: string, description: "Meta image hash, reusable wherever image_hash is accepted." }
url: { type: string, description: "Meta-hosted image URL; usable as imageUrl on the create endpoints." }
'400': { description: "Invalid input, or Meta rejected the image" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
'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.
The default projection covers hash, url, name, dimensions and status; `fields` is a
raw-passthrough override. Any `hash` here is reusable wherever Meta accepts
`image_hash` (e.g. `imageHash` on POST /v1/ads/creatives).
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "Zernio SocialAccount id (posting or ads variant) used to resolve the Meta token." }
- { name: adAccountId, in: query, required: true, schema: { type: string }, description: "Meta ad account id (act_<n>)." }
- { name: fields, in: query, schema: { type: string, 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Ad images (raw Meta shape)
content:
application/json:
schema:
type: object
properties:
adAccountId: { type: string }
data:
type: array
items: { type: object, description: "Raw Meta ad image row (hash, url, name, width, height, status)." }
paging:
type: object
properties:
after: { type: [string, "null"], description: "Cursor for the next page; null when exhausted." }
'400': { description: "Invalid input, or Meta rejected the query" }
'401': { $ref: '#/components/responses/Unauthorized' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/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
be rendered via POST /v1/ads/preview or attached via `video.id` on POST /v1/ads/create
before an ad exists.
Accepts either an https `videoUrl` we download server-side (SSRF-guarded) or raw
`videoBase64` bytes; exactly one is required. `videoBase64` is capped by Vercel's body
limit, around 4.5 MB payload in practice, so larger videos must come via `videoUrl`.
Returns the Meta `video.id` (reusable wherever `video.id` is accepted) plus Meta's
auto-generated poster URL when available. The endpoint waits until Meta reports the
video ready (chunked upload + transcode can take minutes; the handler runs up to
800 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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' }
'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.
The default projection covers id, title, status, poster frames, length and `source` (the
playable MP4); `fields` is a raw-passthrough override. Any `id` here is reusable as
`video.id` on the create endpoints, so N ads that differ only in copy share one upload.
`source` lets you PLAY a video before picking it, which a poster frame alone can't settle
when several videos share a first frame. It is a signed CDN URL that EXPIRES, so treat it
as good for preview at selection time only. Never persist it; re-list to get a fresh one.
This is the only way to reach a video uploaded OUTSIDE Zernio (Ads Manager, another
tool); videos we uploaded also come back as `creative.videoId` on GET /v1/ads.
Meta transcodes asynchronously, so a row is only usable once `status.video_status`
reads `ready`. Upload a new video via POST /v1/ads/videos, or inline via `video.url`
on 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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' }
'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
`DELETE /{video_id}` fails with code 10 / subcode 1363055 for videos uploaded via
`/act_X/advideos` even with `ads_management`; this endpoint uses the working
account-scoped shape `DELETE /act_X/advideos?video_id=<id>` and returns Meta's
`{success: true}` verbatim. Deleting a video that lives in a different ad account,
or 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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' }
'501': { description: Only supported on Meta (facebook/instagram) }
/v1/ads/interests:
get:
x-resource-group: "ads"
operationId: searchAdInterests
deprecated: true
tags: ["Ad Targeting"]
x-platforms: ["meta"]
summary: Search targeting interests
description: |
Deprecated alias for `GET /v1/ads/targeting/search?dimension=interest`. Kept for
backward compatibility, it returns the legacy `{ interests: [...] }` shape rather
than the normalized `{ results: [...] }`. New integrations should use
`GET /v1/ads/targeting/search` with `dimension=interest`.
security:
- bearerAuth: []
parameters:
- { name: q, in: query, required: true, schema: { type: string }, description: Search query }
- { name: accountId, in: query, required: true, schema: { type: string }, description: Account ID }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'400': { $ref: '#/components/responses/BadRequest' }
'200':
description: Matching interests
content:
application/json:
schema:
type: object
properties:
interests:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
category: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
/v1/ads/targeting/search:
get:
x-resource-group: "ads"
operationId: searchAdTargeting
tags: ["Ad Targeting"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest"]
summary: Search targeting options
description: |
Resolve a human-readable query into the platform's opaque targeting ids used in
the `TargetingSpec` (`countries`/`regions`/`cities`/`zips`/`metros` geo keys, and
`interests`/`behaviors` entity ids) on `POST /v1/ads/create`,
`POST /v1/ads/targeting/reach-estimate`, and `saved_targeting` audiences.
The `dimension` param selects what is searched:
- `geo`: locations, further scoped by `geoType`
- `interest`
- `behavior`
- `income`
- `language`: Google-only
- `workPosition`, `workEmployer`, `workIndustry`: the Meta-only work demographics, whose
ids feed `TargetingSpec.workPositions`/`workEmployers`/`workIndustries`
- `industry`, `jobFunction`, `seniority`, `companySize`: the LinkedIn-only B2B facets, whose
URNs feed `TargetingSpec.industries`/`jobFunctions`/`seniorities`/`companySizes`
Availability of each dimension varies by platform (e.g. behaviours are Meta/TikTok only).
Work industries are a fixed ~30-entry Meta catalog with no server-side query,
so `workIndustry` matching, ranking and `limit` happen in Zernio. `language`
is likewise a fixed, checked-in table of Google's targetable
`language_constant` rows (id, ISO code, name) matched by name or code, capped
at 20, with no network call; its ids feed `TargetingSpec.languages`.
Results are normalized across platforms into a single shape, so the same client code
consumes Meta, TikTok, LinkedIn, X, Pinterest, and Google results.
TikTok geo searches return every matching level in one list (`type` is
`country`, `region`, `city`, `district`, or `metro` for DMA areas), and
`geoType` is not applied. Results are scoped to the advertiser's targetable
markets. A `country` result's id is its ISO 3166-1 alpha-2 code, for
`targeting.countries`; every other id is TikTok's numeric location id,
usable in `regions`/`cities`/`metros` keys on `POST /v1/ads/create`.
LinkedIn geo searches also return every matching level in one list, and
neither `geoType` nor `countryCode` is applied: LinkedIn's typeahead only
returns a name and a URN per result, with no level or country field to
filter on. A result whose URN is a country Zernio holds a code for has
`type: country` and its ISO 3166-1 alpha-2 code as the id, for
`targeting.countries`. Every other result has `type: region` and keeps
its `urn:li:geo:*` URN as the id, usable as a `regions[].key` on
`POST /v1/ads/create`, `POST /v1/ads/boost` and
`POST /v1/ads/targeting/reach-estimate` (LinkedIn puts countries and
regions in the same `locations` facet, so both target the same way).
LinkedIn B2B searches (`industry`, `jobFunction`, `seniority`, `companySize`) return the
full URN to pass straight back, so no URN id fragment has to be assembled by hand:
`urn:li:industry:4`, `urn:li:function:8`, `urn:li:seniority:6`,
`urn:li:staffCountRange:(51,200)`. Only `industry` is a server-side name search
(LinkedIn's typeahead finder). LinkedIn exposes no typeahead for job functions,
seniorities and company sizes, so Zernio fetches each whole table (26, 10 and 9 entries),
caches it, and does the matching, ranking and `limit` cutoff itself. Those three never
carry `audienceSize`, and `countryCode` and `geoType` are not applied to any of the four.
Google geo searches resolve against Google's geoTargetConstants and return
every matching level in one list; `geoType` is not applied (Google's
`target_type` is an open taxonomy that does not map one-to-one onto the
`geoType` enum), so filter client-side on the returned `type` (`country`,
`region`, `city`, `zip`, `metro`, or the lowercased Google target type for
rarer levels). `countryCode` scopes the search to one country. A `country`
result's id is its ISO 3166-1 alpha-2 code, for `targeting.countries`;
every other id is Google's numeric criterion id, usable as a
`regions`/`cities`/`zips`/`metros` `key` on `POST /v1/ads/create`. Google
city radius is not supported (pass a `customLocations` lat/lng pin for a
radius); country targeting also accepts plain ISO codes via `countries`
with no search call.
Pinterest resolves against three whole-catalog endpoints (interests, locations,
regions) with no server-side query or pagination, so matching, ranking and the
`limit` cutoff all happen in Zernio; the catalog is independent of any ad account
and results never carry `audienceSize`. Names come back localized to the connected
Pinterest account's language (there is no way to force a locale), so match against
whatever language that account returns.
`geoType` routes to a different Pinterest catalog:
- `country` and `metro_area` read the locations catalog (`type` is `country` or `metro`)
- `region` reads the regions catalog (`type` is `region`, its id a `regions[].key` on
`POST /v1/ads/create`)
- `all` and the default `city` merge both catalogs with honest per-entry `type`s, since
Pinterest has no city-level catalog and `city` is an alias for `all`, not a literal
city search
- `zip`, `subcity`, `neighborhood`, `place` and `geo_market` return a 400: Pinterest
exposes no postal-code catalog, pass postal codes directly as
`targeting.zips: [{ key }]` on `POST /v1/ads/create`
For geo queries, `q` should contain only the locality name (e.g. `"Amsterdam"`,
not `"Amsterdam, NL"`). Use `countryCode` to disambiguate.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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. A `country` result is the exception on every platform: its id is the ISO 3166-1 alpha-2 code, which is what `targeting.countries` takes." }
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' }
/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
(`GET /ads_archive`) is searched with Zernio's own developer access, so `platform=meta` needs
no connected account at all. LinkedIn's Ad Library (`GET /rest/adLibrary`) runs on a connected
`linkedin` / `linkedinads` account, passed as `accountId`. Passing a Meta account as `accountId`
also selects Meta. Rows are returned in the platform's raw shape under `data`; `paging.after`
is an opaque cursor on both (`null` when exhausted).
**Meta coverage.** Political and social-issue ads are searchable worldwide. Every other ad is
in the archive only if it was delivered to the EU or UK within the last year, so a US-only
commercial advertiser is invisible. Spend, impressions and demographics are political-only
fields and are left out of the default projection; request them via `fields`. All customers
share Zernio's Meta quota, so a `429` means back off for a minute.
**LinkedIn coverage.** Ads served after June 1 2023, worldwide, kept for a year after their
last impression. EU-delivered ads carry impression ranges and the disclosed targeting facets.
Pages are capped at 25 ads (`limit` > 25 is a 400); `after` is the next offset.
Which params apply: `q`, `countries`, `since`, `until`, `limit`, `after` on both; `pageIds`,
`adType`, `status`, `platforms`, `mediaType`, `languages`, `searchType`, `fields` are Meta-only;
`advertiser` is LinkedIn-only. Passing a param the account's platform does not support is a 400
naming 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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,
before any campaign is created. Backed by each platform's native reach API
(Meta `delivery_estimate`, LinkedIn `audienceCounts`, X `audience_summary`,
Pinterest `audience_sizing`).
Platforms without a usable pre-flight reach API (Google Search/Display, TikTok)
return `available: false` with no bounds, so clients can hide or grey out the
estimate rather than treat the absence as an error.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, spec]
properties:
accountId: { type: string, description: "Zernio account ID on the target ad platform (the estimate runs against its platform)." }
adAccountId: { type: string, description: "Required. The platform ad-account ID the reach call runs against (Meta act_..., LinkedIn numeric sponsoredAccount ID, Pinterest ad-account ID, X account ID) - every backing reach API is scoped to one ad account. Get it from GET /v1/ads/accounts." }
spec:
allOf: [{ $ref: '#/components/schemas/TargetingSpec' }]
description: "The targeting spec to estimate. Same shape used by POST /v1/ads/create."
optimizationGoal:
type: string
description: |
Optional. The optimization goal the estimate should assume (platform's
own vocabulary, e.g. Meta `REACH`, `LINK_CLICKS`, `OFFSITE_CONVERSIONS`).
Some platforms vary the estimate by goal; omit to use the platform default.
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
/v1/ads/targeting/bid-pricing:
post:
x-resource-group: "ads"
operationId: getLinkedInBidPricing
tags: ["Ad Targeting"]
x-platforms: ["linkedin"]
summary: Suggested bid and budget bounds
description: |
LinkedIn-only. Returns the suggested bid and bid limits for a targeting
spec, plus the daily-budget bounds LinkedIn will accept. Use it before
creating a campaign to pick a bid inside the allowed range and warn the
user if their daily budget is below the minimum. Wraps LinkedIn's
`adBudgetPricing` finder.
Non-LinkedIn accounts return `available: false` so clients can hide the
pricing UI without treating it as a failure.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, spec]
properties:
accountId: { type: string, description: "Zernio 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
/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
metrics for a targeting spec over a time range. Wraps LinkedIn's
`adSupplyForecasts` finder.
Each returned series carries a `metricType` (IMPRESSION, CLICK, SPENDING,
MAX_POTENTIAL_BUDGET, COST_PER_MILLION_IMPRESSIONS, ...) and a
`granularity` (DAILY, SEVEN_DAY, THIRTY_DAY, CUSTOM). LinkedIn caps the
daily spending forecast at 1.2x the daily budget and returns 0 once the
total budget is exhausted.
Non-LinkedIn accounts return `available: false`.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, adAccountId, spec, timeRangeStart, timeRangeEnd]
properties:
accountId: { type: string }
adAccountId: { type: string }
spec: { allOf: [{ $ref: '#/components/schemas/TargetingSpec' }] }
campaignType: { type: string, enum: [SPONSORED_UPDATES, SPONSORED_INMAILS, DYNAMIC], description: "Defaults to SPONSORED_UPDATES." }
timeRangeStart: { type: integer, description: "Unix ms. Must be in the future." }
timeRangeEnd: { type: integer, description: "Unix ms. Must be after start and within LinkedIn's max horizon." }
objectiveType: { type: string }
optimizationTarget: { type: string, description: "When set, the forecast assumes auto-bidding. When unset, competingBid is required." }
dailyBudget: { type: number, description: "Either dailyBudget or totalBudget is required." }
totalBudget: { type: number }
currency: { type: string, minLength: 3, maxLength: 3, description: "ISO 4217, defaults to USD." }
competingBid:
type: object
required: [bidType, amount]
description: "Required for manual-bid forecasts (when optimizationTarget is not set)."
properties:
bidType: { type: string, enum: [CPM, CPC, CPV] }
amount: { type: number }
enableAudienceNetwork: { type: boolean, description: "Defaults to false. Required true for connectedTelevisionOnly." }
enableAudienceExpansion: { type: boolean, description: "Defaults to false." }
connectedTelevisionOnly: { type: boolean, description: "Defaults to false." }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
/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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Catalogs
content:
application/json:
schema:
type: object
properties:
catalogs:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
vertical: { type: [string, "null"], description: "Catalog vertical (e.g. commerce, vehicles, hotels)" }
productCount: { type: integer }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
/v1/ads/catalogs/{catalogId}/product-sets:
get:
x-resource-group: "ads"
operationId: listAdCatalogProductSets
tags: ["Ad Creatives"]
x-platforms: ["meta"]
summary: List a catalog's product sets
description: "Lists a Meta product catalog's product sets, the unit a catalog ad promotes. Pass the chosen set 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Product sets
content:
application/json:
schema:
type: object
properties:
productSets:
type: array
items:
type: object
properties:
id: { type: string }
name: { type: string }
productCount: { type: integer }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
/v1/ads/audiences:
get:
x-resource-group: "ads"
operationId: listAdAudiences
tags: ["Ad Audiences"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: List custom audiences
description: Returns custom audiences for the given ad account. Supports Meta, Google, TikTok, Pinterest, LinkedIn, and X.
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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'400': { $ref: '#/components/responses/BadRequest' }
'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 }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
post:
x-resource-group: "ads"
operationId: createAdAudience
tags: ["Ad Audiences"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: Create custom audience
description: |
Create a custom audience. `customer_list` is supported on Meta, Google, X, LinkedIn, TikTok, and Pinterest;
`website` and `lookalike` are Meta-only; `company_list`, `engagement` and `website_retargeting` are LinkedIn-only.
`saved_targeting` stores a reusable TargetingSpec (no member upload, no adAccountId) that you reference later via
`savedTargetingId` on `POST /v1/ads/create`.
How the audience gets filled depends on the type:
- `customer_list` is created empty. Add members with `POST /v1/ads/audiences/{audienceId}/users`.
On TikTok and Pinterest the audience is provisioned lazily on that first upload (until then its status is `pending`).
- `company_list` is filled AT CREATION from the `companies` array below, which is required. To change the list
afterwards send the new full list to `POST /v1/ads/audiences/{audienceId}/companies` (a replace, not a merge).
The `/users` endpoint rejects these audiences with a 422.
- `website`, `website_retargeting`, `engagement`, `meta_engagement` and `lookalike` fill themselves from the pixel,
engagement source or seed audience you point them at. They take no member upload at all.
Create is not idempotent, never auto-retry.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
oneOf:
- type: object
title: UploadedOrDerivedAudience
description: "customer_list, website, or lookalike audience (uploaded or derived from a source)."
required: [accountId, adAccountId, name, type]
properties:
accountId: { type: string }
adAccountId: { type: string, description: "Platform ad account ID. Must start with act_ for Meta; bare platform id for others (Google customer id, X/TikTok/LinkedIn/Pinterest account id)." }
name: { type: string, maxLength: 255 }
description: { type: string }
type: { type: string, enum: [customer_list, company_list, engagement, meta_engagement, website, website_retargeting, lookalike] }
matchRules:
type: array
minItems: 1
maxItems: 50
description: |
Required for website_retargeting audiences (LinkedIn only).
Each rule is a URL pattern; a member who visits any
matching page enters the segment. Needs the LinkedIn
Insight Tag installed on the customer's site; the
segment only starts filling once the tag reports visits.
The response's `platformAudienceId` is the LinkedIn
adSegment id, valid for downstream use.
These segments appear in GET /v1/ads/audiences with
`type: website_retargeting` once LinkedIn has finished building them.
items:
type: object
additionalProperties: false
required: [matchType, matchValue]
properties:
matchType: { type: string, enum: [EXACT, STARTS_WITH, CONTAINS, ENDS_WITH] }
matchValue: { type: string }
sourceType:
type: string
enum: [VIDEO_ADS, LEAD_GEN_FORMS, ORGANIZATION_PAGES, EVENT_PAGES, SINGLE_IMAGE_ADS]
description: |
Required for engagement audiences (LinkedIn only): what
members engaged with: a video/leadgen/single-image ad
campaign, a Company Page or an Event page.
trigger:
type: string
description: |
Required for engagement audiences. The action, validated
by LinkedIn against `sourceType`. Common values:
VIDEO_ADS FIRST_QUARTILE / MIDPOINT / THIRD_QUARTILE /
FULL_COMPLETE; LEAD_GEN_FORMS VIEW_FORM /
LEAD_FORM_SUBMIT; ORGANIZATION_PAGES VIEW / CTA_CLICK;
EVENT_PAGES RSVPED / VIDEO_VIEWED / ENGAGEMENT / CLICK.
lookbackDays:
type: integer
enum: [30, 60, 90, 180, 365]
description: Required for engagement audiences. Rolling window.
engagementSources:
type: array
minItems: 1
maxItems: 50
description: |
Required for engagement audiences. Campaign URNs for the
ad source types, organization URNs for pages and events.
LinkedIn creates one rule per source, all sharing the
same trigger and lookbackDays.
items: { type: string }
companies:
type: array
minItems: 1
maxItems: 300000
description: |
Required for company_list audiences (LinkedIn only): plain-text
company rows for account targeting. Each row needs at least one
identifier. Not hashed, LinkedIn matches these against its own
company graph. LinkedIn recommends 1,000+ companies for a usable
match rate and takes up to 48h to process the list. Replace the
list later with POST /v1/ads/audiences/{audienceId}/companies.
items:
type: object
additionalProperties: false
properties:
name: { type: string, maxLength: 255 }
domain: { type: string, maxLength: 100 }
website: { type: string, maxLength: 100 }
linkedinPageUrl: { type: string }
pixelId: { type: string, description: Required for website audiences }
retentionDays: { type: integer, minimum: 1, maximum: 365, description: "Required for website (max 180) and meta_engagement (max 365) audiences." }
engagementSource:
type: string
enum: [page, instagram, video]
description: |
Required for meta_engagement audiences (Meta only): what people
engaged with. `page` = a Facebook Page, `instagram` = an IG
professional account, `video` = a video.
sourceId: { type: string, description: "Required for meta_engagement: the Page / IG account / video id." }
event:
type: string
description: |
meta_engagement only. The engagement event; defaults per source
(page → page_engaged, instagram → ig_business_profile_all,
video → video_watched). Ignored when `rule` is provided.
sourceAudienceId: { type: string, description: Required for lookalike audiences }
country: { type: string, description: "2-letter code, required for lookalike audiences" }
ratio: { type: number, minimum: 0.01, maximum: 0.20, description: Required for lookalike audiences }
urlContains:
type: string
description: |
website only. Narrows the audience from all visitors to visitors of
URLs containing this substring. Ignored when `rule` is supplied.
rule:
type: object
description: |
Optional raw Meta rule, replacing the one we build. Omit it for all
visitors of `pixelId`, or use `urlContains` for the common page-match
case.
For `website` this is Meta's Flexible Audience Rule and is VALIDATED
before we call Meta: every entry in `inclusions.rules` (and
`exclusions.rules`) must carry `event_sources`, `retention_seconds`
AND `filter`. Meta rejects a rule missing any of the three with code
100 / subcode 1713098 ("Invalid rule JSON format"), so a bad shape is
a 400 here instead. The pre-2018 flat shapes (`{url: ...}`,
`{event: ...}`) are not accepted by Meta at all (subcode 1870029).
Example, visitors of /checkout in the last 30 days:
`{"inclusions":{"operator":"or","rules":[{"event_sources":[{"id":"<pixelId>","type":"pixel"}],"retention_seconds":2592000,"filter":{"operator":"and","filters":[{"field":"url","operator":"i_contains","value":"/checkout"}]}}]}}`
Note Meta DERIVES `retention_days` from `retention_seconds` and
stores `event_sources[].id` as a number, so a rule read back will
not be byte-identical to the one you sent.
For `meta_engagement` the rule is forwarded verbatim and NOT
validated: that type has two dialects (the `video` source uses a
legacy flat array), so no single schema covers both.
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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'201':
description: Audience created
content:
application/json:
schema:
type: object
properties:
audience: { type: object }
message: { type: string }
'400':
description: Missing required fields
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
/v1/ads/audiences/{audienceId}:
get:
x-resource-group: "ads"
operationId: getAdAudience
tags: ["Ad Audiences"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: Get audience details
description: Returns the local audience record and fresh data from Meta (if available).
security:
- bearerAuth: []
parameters:
- { name: audienceId, in: path, required: true, schema: { type: string }, 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`
(full replacement, no merge, Zernio-only, no platform call). Platform audiences
(uploaded/website/lookalike) accept `name` and `description` only, updated on the
platform first and then mirrored locally; their rules are immutable, so `spec` returns
400 for them. Platform audience updates are Meta-only for now (other platforms return
501). Ads already created from a saved_targeting audience are unaffected, they snapshot
the targeting at creation.
security:
- bearerAuth: []
parameters:
- { name: audienceId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
description: At least one field is required.
properties:
name: { type: string, maxLength: 255 }
description: { type: string }
spec:
allOf: [{ $ref: '#/components/schemas/TargetingSpec' }]
description: Full replacement for the stored targeting spec.
responses:
'200':
description: Audience updated
content:
application/json:
schema:
type: object
properties:
audience: { type: object }
message: { type: string }
'400':
description: 'Invalid body (no fields provided, malformed spec, or spec on a platform audience)'
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
'404': { $ref: '#/components/responses/NotFound' }
'422':
description: The audience has no platform counterpart to update
'501':
description: Platform audience updates are only supported on Meta
delete:
x-resource-group: "ads"
operationId: deleteAdAudience
tags: ["Ad Audiences"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: Delete custom audience
description: Deletes the audience from both the platform and the local database. `saved_targeting` audiences exist only on Zernio, so only the local record is removed.
security:
- bearerAuth: []
parameters:
- { name: audienceId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: Audience deleted
content:
application/json:
schema:
type: object
properties:
message: { type: string }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required. Legacy plans need the Ads add-on; included by default on usage-based plans.
'404': { $ref: '#/components/responses/NotFound' }
/v1/ads/audiences/{audienceId}/users:
post:
x-resource-group: "ads"
operationId: addUsersToAdAudience
tags: ["Ad Audiences"]
x-platforms: ["meta", "google", "tiktok", "linkedin", "pinterest", "x"]
summary: Add users to audience
description: |
Upload user data to a customer_list audience. Data is SHA256-hashed server-side before sending to the platform.
Email is used on every platform; phone is used on Meta only (other platforms ignore it). On TikTok and Pinterest,
the first upload also provisions the audience (deferred create). LinkedIn uploads are full-replace. Max 10,000 users per request.
customer_list only. A LinkedIn `company_list` audience takes company rows, not people: send those to
`POST /v1/ads/audiences/{audienceId}/companies`. This endpoint 422s for every other audience type.
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).
LinkedIn-only, every other platform returns 422.
A LinkedIn audience segment holds exactly one uploaded list, so the list you send here
REPLACES the segment's list instead of being appended to it: always send the full set of
companies. LinkedIn returns only the identifier of the uploaded file, never its rows, so the
merge cannot be done for you, keep the source list on your side.
How the matching behaves:
- Rows are plain text (not hashed), matched against LinkedIn's own company graph.
- Matching is asynchronous: LinkedIn takes up to 48h for a new audience and up to 24h for a
later update, and the audience stays `processing` meanwhile.
- LinkedIn does not document how quickly companies dropped from the list stop being targeted,
so treat removals as eventual rather than immediate.
- LinkedIn recommends at least 1,000 companies for a usable match rate, and caps a list at
300,000.
The initial list is sent with `companies` on `POST /v1/ads/audiences`; this endpoint is for
every change after that.
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
pixel/dataset, live from Meta's Dataset Quality API. Web events only (a
Meta limitation). Meta-only; other platforms return 405. Requires the Ads add-on.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: "SocialAccount _id (must be a metaads account)." }
- { name: destinationId, in: query, required: true, schema: { type: string }, description: "Meta pixel/dataset ID." }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'200':
description: Match-quality rows, one per event name.
content:
application/json:
schema:
type: object
properties:
platform: { type: string, example: metaads }
rows:
type: array
items:
type: object
properties:
eventName: { type: string }
compositeScore: { type: number, description: "Composite EMQ score, 0-10." }
matchKeys:
type: array
items:
type: object
properties:
identifier: { type: string }
coveragePercentage: { type: number }
eventCoveragePercentage: { type: number, description: "Pixel↔CAPI coverage rate for this event." }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'405': { description: "Platform does not expose Event Match Quality (non-Meta)." }
/v1/ads/conversions:
post:
x-resource-group: "ads"
operationId: sendConversions
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: Send conversion events
description: |
Relay one or more conversion events to the target ad platform's native Conversions API.
Platform is inferred from the provided `accountId`. Requires the Ads add-on.
Supported platforms:
- Meta (`metaads`) via Graph API
- Google Ads (`googleads`) via Data Manager API `ingestEvents`
- LinkedIn (`linkedinads`) via `/rest/conversionEvents`
- TikTok (`tiktokads`) via the Offline Events API `/offline/batch/` (OFFLINE conversions only)
- OpenAI Ads (`openaiads`) via its Conversions API (a separate host, `bzr.openai.com`)
`destinationId` semantics differ per platform:
- Meta: pixel (dataset) ID, e.g. `123456789012345`
- Google: conversion action resource name, e.g. `customers/1234567890/conversionActions/987654321`
- LinkedIn: conversion rule ID or URN, e.g. `104012` or `urn:lla:llaPartnerConversion:104012`
- TikTok: Offline Event Set ID, e.g. `7057103914977558530`
- OpenAI Ads: pixel wire id (numeric `pixel_id`, distinct from the internal pixel id), as returned by `GET /v1/accounts/{accountId}/conversion-destinations`
TikTok notes: this path sends OFFLINE conversions (in-store / CRM / call-center), not web-pixel
events. Each event must carry an email or phone (TikTok requires at least one). The connected
TikTok ads account must have granted the Offline Events permission; older grants must reconnect.
OpenAI Ads notes: requires a tracking tag (pixel) to already exist on the account. Returns 422
with code `TRACKING_TAG_REQUIRED` if `POST /v1/accounts/{accountId}/tracking-tags` hasn't been
called yet.
Callers can list valid destinations via `GET /v1/accounts/{accountId}/conversion-destinations`.
All PII (email, phone, names, external IDs) is hashed with SHA-256 server-side per each
platform's normalization spec, including Google's Gmail-specific dot/plus-suffix stripping.
Send plaintext. LinkedIn `externalIds` are passed through as plaintext per LinkedIn's spec;
only emails and phones are hashed.
For LinkedIn, the connected account must have been authorized after the Conversions API
rollout (i.e. the OAuth grant must include `rw_conversions`). Older accounts must reconnect.
Batching is handled automatically. Meta caps at 1000 events per request and rejects the
entire batch if any event is malformed. Google caps at 2000. LinkedIn caps at 5000 and is
also all-or-nothing per chunk. OpenAI Ads caps at 1000 per request; larger submissions are
split into 1000-event chunks, each all-or-nothing (a malformed event fails every event in
that chunk, not the whole request).
Dedup: pass a stable `eventId` on every event. Meta and LinkedIn use it to dedupe against
browser-side pixel/Insight Tag events; Google maps it to `transactionId`.
Per-platform `eventName` semantics:
- Meta: free-form. Standard names (Purchase, Lead, ...) match Meta's built-in events; custom strings are accepted.
- Google: ignored. The conversion action's category determines the event type. Send the standard name closest to your action for documentation, but the platform will not branch on it.
- LinkedIn: ignored. The conversion rule's `type` (LEAD, PURCHASE, etc.) is locked to the destination at rule-creation time. Send the standard name for documentation; LinkedIn does not branch on it.
- OpenAI Ads: a fixed subset of standard names (Purchase, Lead, AddToCart, ViewContent, InitiateCheckout, CompleteRegistration, Subscribe, StartTrial, Schedule) maps 1:1 onto OpenAI's own event-type enum; any other standard name or custom string is sent as `type: custom` with the name preserved.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, destinationId, events]
properties:
accountId:
type: string
description: SocialAccount ID (metaads, googleads, linkedinads, tiktokads, or openaiads).
destinationId:
type: string
description: |
Platform destination identifier. For Meta, the pixel/dataset
ID. For Google, the conversion action resource name. For
LinkedIn, the conversion rule ID or full
`urn:lla:llaPartnerConversion:{id}` URN. For OpenAI Ads, the
pixel wire id.
events:
type: array
minItems: 1
items: { $ref: '#/components/schemas/ConversionEvent' }
testCode:
type: string
description: Meta `test_event_code` passthrough. Ignored by Google, LinkedIn, and OpenAI Ads.
consent:
type: object
description: |
Batch-level user consent. Required by Google for EEA/UK
events under the Feb 2026 restrictions. On Meta, any
DENIED flag enables Limited Data Use on every event in
the batch (data_processing_options ["LDU"] with
geolocation, country 0 / state 0); GRANTED or absent
consent sends events with Meta's default processing.
Ignored by LinkedIn.
properties:
adUserData: { type: string, enum: [GRANTED, DENIED] }
adPersonalization: { type: string, enum: [GRANTED, DENIED] }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'200':
description: |
Events processed. Inspect `eventsFailed` and `failures[]` to detect
partial failure. For Meta, a batch is all-or-nothing (either every
event in a chunk succeeds, or every event in the chunk is listed
in failures). For Google, the API returns success/failure at the
request level only. For OpenAI Ads, each 1000-event chunk is
all-or-nothing, same as Meta.
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [metaads, googleads, linkedinads, tiktokads, openaiads] }
eventsReceived: { type: integer, description: Events accepted by the platform. }
eventsFailed: { type: integer, description: Events rejected (see failures). }
failures:
type: array
items:
type: object
properties:
eventIndex: { type: integer, description: Index into the submitted events array. }
eventId: { type: string, description: Echoes back the eventId of the failed event. }
message: { type: string }
code: { oneOf: [{ type: string }, { type: integer }] }
traceId:
type: string
description: |
Platform trace ID for debugging. fbtrace_id for Meta,
requestId for Google. Absent for LinkedIn (LinkedIn's
conversionEvents endpoint does not surface a trace ID)
and OpenAI Ads (no trace ID surfaced).
'400':
description: Invalid body (missing accountId/destinationId/events, malformed event shape).
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: |
Ads access required (Ads add-on on legacy plans, included on usage-based plans),
OR (for LinkedIn) the connected account lacks the `rw_conversions` scope and must be reconnected.
'404': { $ref: '#/components/responses/AccountUnavailable' }
'422':
description: 'OpenAI Ads only: no tracking tag (pixel) exists yet for this account. Code `TRACKING_TAG_REQUIRED`; create one via `POST /v1/accounts/{accountId}/tracking-tags` first.'
'429':
description: |
LinkedIn token-level rate limit hit (600 requests/min, 300k/day
per token). Retry with backoff. Meta and Google have their own
rate-limit semantics surfaced via platform-specific 4xx responses.
/v1/ads/conversions/adjustments:
post:
x-resource-group: "ads"
operationId: adjustConversions
tags: [Conversions]
x-platforms: ["meta"]
summary: Adjust uploaded conversions
description: |
Adjust conversions that were previously uploaded via `POST /v1/ads/conversions`:
retract them, restate their value, or enhance them with first-party data. Requires
the Ads add-on.
**Google Ads only.** Google handles adjustments through the classic Google Ads API
(`ConversionAdjustmentUploadService`); the Data Manager `ingestEvents` path used for
sending conversions is ingest-only. Meta and LinkedIn have no equivalent, so this
endpoint returns `405` for those platforms.
Adjustment types:
- `RETRACTION`: remove the conversion entirely (refund, chargeback, cancelled order, churn).
- `RESTATEMENT`: change the conversion's value (upgrade / downgrade / partial refund). Send the corrected **total** value in `restatementValue` (not a delta).
- `ENHANCEMENT`: attach first-party identifiers (hashed email / phone) to an existing conversion (enhanced conversions applied after the fact).
Identifying the original conversion (per adjustment):
- `orderId`: the transaction ID you sent as `eventId` on the original conversion. Recommended, and **required** for `ENHANCEMENT`.
- or `gclid` + `conversionTime`: the click ID and the original conversion's time (unix seconds). Not available for `ENHANCEMENT`.
`destinationId` is the conversion action resource name, e.g.
`customers/1234567890/conversionActions/987654321` (same value you send to
`POST /v1/ads/conversions`). PII in `user` is hashed with SHA-256 server-side
(Gmail-specific normalization included). Send plaintext.
Times are unix seconds; we convert to Google's required
`yyyy-MM-dd HH:mm:ss+00:00` format. Up to 2000 adjustments per request; partial
failure is supported (inspect `adjustmentsFailed` / `failures[]`).
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, destinationId, adjustments]
properties:
accountId:
type: string
description: SocialAccount ID. Must be a `googleads` account.
destinationId:
type: string
description: Conversion action resource name, e.g. `customers/1234567890/conversionActions/987654321`.
adjustments:
type: array
minItems: 1
maxItems: 2000
items:
type: object
required: [adjustmentType, adjustmentTime]
properties:
adjustmentType:
type: string
enum: [RETRACTION, RESTATEMENT, ENHANCEMENT]
adjustmentTime:
type: number
description: When the adjustment occurred, unix seconds.
orderId:
type: string
description: Transaction ID of the original conversion (the `eventId` you sent). Recommended; required for ENHANCEMENT.
gclid:
type: string
description: Alternative key. The original click ID. Pair with `conversionTime`. Not valid for ENHANCEMENT.
conversionTime:
type: number
description: The original conversion's time, unix seconds. Required when identifying by `gclid`.
restatementValue:
type: number
description: RESTATEMENT only. The corrected TOTAL conversion value.
currency:
type: string
description: RESTATEMENT only. ISO 4217 currency for `restatementValue`.
user:
type: object
description: ENHANCEMENT only. First-party identifiers (hashed server-side). At least one of email/phone required.
properties:
email: { type: string }
phone: { type: string }
userAgent:
type: string
description: ENHANCEMENT only. The original conversion's user agent (improves match quality).
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'200':
description: |
Adjustments processed. Inspect `adjustmentsFailed` and `failures[]` for
partial failure (Google reports per-row errors via partial failure).
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [googleads] }
adjustmentsReceived: { type: integer, description: Adjustments accepted by Google. }
adjustmentsFailed: { type: integer, description: Adjustments rejected (see failures). }
failures:
type: array
items:
type: object
properties:
adjustmentIndex: { type: integer, description: Index into the submitted adjustments array. }
message: { type: string }
code: { oneOf: [{ type: string }, { type: integer }] }
traceId: { type: string }
'400':
description: Invalid body, or a malformed adjustment (missing key, missing restatementValue for RESTATEMENT, missing identifiers for ENHANCEMENT).
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: Ads access required (Ads add-on on legacy plans, included on usage-based plans).
'404': { $ref: '#/components/responses/AccountUnavailable' }
'405':
description: Conversion adjustments are only available for Google Ads (the account's platform is not `googleads`).
/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
default. Each action's `tagSnippets` (global site tag + event snippet) is
included when Google has them for that action's type, e.g. `WEBPAGE`.
Google-only; other platforms return `501`. Requires the Ads add-on.
`customerId` is optional: when omitted, it is resolved from the connection's
accessible Google Ads customers, and the call fails with `400` when more than
one is accessible (pass `customerId` to disambiguate).
The list itself is cached for the quota window (1 hour fresh, up to 7 days
last-good; the cache key does not vary on `type`). 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: "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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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
its tag snippets, read back after creation since Google never returns them on
the create response itself. Invalidates the cached list `GET` on this resource
would otherwise keep serving. Google-only; other platforms return `501`.
Requires the Ads add-on.
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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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' }
'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),
conversion rules (LinkedIn), or pixels (OpenAI Ads) accessible to the
connected ads account. Use the returned `id` as `destinationId` when
posting to `POST /v1/ads/conversions`.
For Google and LinkedIn, each destination's `type` reflects the
conversion type (PURCHASE, LEAD, SIGN_UP, etc.), and the event type is
locked to the destination. For Meta and OpenAI Ads, `type` is absent:
pixels accept any event name per request.
For LinkedIn, destinations are returned across every sponsored ad
account the connected token can access; the `adAccountId` field on
each destination identifies the parent ad account and is required for
subsequent CRUD calls (update, delete, associations, metrics).
security:
- bearerAuth: []
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: SocialAccount ID (metaads, googleads, linkedinads, tiktokads, or openaiads).
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'200':
description: Destinations listed
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [metaads, googleads, linkedinads, tiktokads, openaiads] }
destinations:
type: array
items:
type: object
properties:
id:
type: string
description: |
Destination identifier. Meta: pixel ID. Google:
conversion action resource name. LinkedIn:
numeric conversion rule ID. OpenAI Ads: pixel wire
id.
name: { type: string }
type:
type: string
description: |
Present when the platform locks event type to the
destination (Google conversion actions, LinkedIn
conversion rules).
status: { type: string, enum: [active, inactive] }
adAccountId:
type: string
description: |
Set by adapters whose destinations are scoped to a
specific ad account (LinkedIn). Pass back on
subsequent CRUD calls.
'400':
description: Account's platform is not supported by the Conversions API.
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: |
Ads access required (Ads add-on on legacy plans, included on usage-based plans),
OR (for LinkedIn) the connected account lacks the `rw_conversions` scope and must be reconnected.
'404': { $ref: '#/components/responses/AccountUnavailable' }
'429':
description: LinkedIn rate limit hit. Retry with backoff.
post:
x-resource-group: "ads"
operationId: createConversionDestination
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: Create a conversion destination
description: |
Create a new conversion destination on the platform. Supported for
LinkedIn (conversion rule) and Google Ads (conversion action). Meta
and OpenAI Ads pixels are created via their own tracking-tags flow
instead (`POST /v1/accounts/{accountId}/tracking-tags`); this endpoint
returns 405 for both.
**LinkedIn:** creation is NOT idempotent. A retry creates a second
destination. Deduplicate before retrying.
**Google Ads:** calling with a name that already exists reuses the
existing conversion action transparently (the response is identical to
a fresh create). Calling with the same name but a different category
returns a typed `IDEMPOTENCY_CONFLICT` (409) rather than silently
returning the mismatched action.
**LinkedIn:** the rule is created with `conversionMethod=CONVERSIONS_API`
and (by default) auto-associated with all of the ad account's campaigns
via `autoAssociationType=ALL_CAMPAIGNS`. Pass `autoAssociationType: NONE`
to opt out and manage associations explicitly via the associations
endpoints below.
365-day attribution windows are only valid for `SUBMIT_APPLICATION`,
`PURCHASE`, `ADD_TO_CART`, `QUALIFIED_LEAD`, and `LEAD` rule types;
the API rejects other combinations locally.
**Google Ads:** the conversion action is created with
`type=UPLOAD_CLICKS` (required for API-uploaded offline conversions,
immutable after creation). The `type` field carries the Google
`ConversionActionCategory` enum value, e.g. `PURCHASE`,
`SUBSCRIBE_PAID`, `SIGNUP`, `IMPORTED_LEAD`, `BOOK_APPOINTMENT`.
Unified standard event names (e.g. `Purchase`, `Subscribe`,
`CompleteRegistration`, `Lead`, `Schedule`) are resolved to their
Google category equivalents automatically. The action defaults to
secondary (non-primary) to avoid immediately steering Smart Bidding;
pass `primaryForGoal: true` to opt in.
security:
- bearerAuth: []
parameters:
- name: accountId
in: path
required: true
schema: { type: string }
description: SocialAccount ID (linkedinads or googleads).
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [adAccountId, name, type]
properties:
adAccountId:
type: string
description: |
Ad account ID. For LinkedIn: numeric (e.g. "5123456") or
full `urn:li:sponsoredAccount:{id}` URN. For Google: numeric
customer ID (e.g. "1234567890") or `customers/{id}` form.
name: { type: string, maxLength: 255 }
type:
type: string
description: |
Conversion type. For LinkedIn: a unified standard event name
(e.g. "Purchase", "Lead", "AddToCart") or a LinkedIn rule
type enum (e.g. "PURCHASE", "QUALIFIED_LEAD"). For Google:
a unified standard event name (Purchase, Subscribe,
CompleteRegistration, Lead, Schedule) or a Google
ConversionActionCategory enum value directly (e.g.
"PURCHASE", "SUBSCRIBE_PAID", "SIGNUP", "IMPORTED_LEAD",
"BOOK_APPOINTMENT"). Unknown values pass through to the
platform.
attributionType:
type: string
enum: [LAST_TOUCH_BY_CAMPAIGN, LAST_TOUCH_BY_CONVERSION]
description: LinkedIn only.
postClickAttributionWindowSize:
type: integer
enum: [1, 7, 30, 90, 365]
description: |
LinkedIn only. Default 30. 365 only allowed for LEAD,
PURCHASE, ADD_TO_CART, QUALIFIED_LEAD, SUBMIT_APPLICATION
rule types; the API rejects other combinations locally.
viewThroughAttributionWindowSize:
type: integer
enum: [1, 7, 30, 90, 365]
description: |
LinkedIn only. Default 7. Same 365-day-window type
restriction applies as `postClickAttributionWindowSize`.
valueType:
type: string
enum: [DYNAMIC, FIXED, NO_VALUE]
description: |
LinkedIn only. DYNAMIC (default) uses the per-event `value`
from `sendConversions`. FIXED uses the rule's `value` field.
NO_VALUE drops monetary value entirely.
value:
type: object
required: [currencyCode, amount]
description: |
LinkedIn only. Static conversion value. Used when
`valueType=FIXED`. The currency should match the ad
account's currency.
properties:
currencyCode: { type: string, minLength: 3, maxLength: 3, description: ISO 4217 (e.g. "USD"). }
amount: { type: string, description: 'Decimal string (e.g. "49.99").' }
autoAssociationType:
type: string
enum: [ALL_CAMPAIGNS, OBJECTIVE_BASED, NONE]
default: ALL_CAMPAIGNS
description: |
LinkedIn only. Controls campaign association at rule-creation
time:
- ALL_CAMPAIGNS: associate the rule with every active,
paused, and draft campaign in the ad account
- OBJECTIVE_BASED: associate only campaigns whose
objective matches the rule's type
- NONE: don't auto-associate. Manage associations via
the `/associations` endpoints below.
Note: auto-association runs once at create time; new
campaigns added after the rule still need explicit
association.
countingType:
type: string
enum: [MANY_PER_CLICK, ONE_PER_CLICK]
description: |
Google Ads only. Whether to count multiple conversions from
the same click (MANY_PER_CLICK) or at most one
(ONE_PER_CLICK). Defaults to MANY_PER_CLICK if omitted.
primaryForGoal:
type: boolean
description: |
Google Ads only. When true, the conversion action is marked
as primary and immediately influences Smart Bidding. Defaults
to false (secondary, record-only) to avoid unintentionally
steering the customer's campaigns on creation.
responses:
'201':
description: Destination created
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [linkedinads, googleads] }
destination: { $ref: '#/components/schemas/ConversionDestination' }
'400':
description: Invalid body or platform validation failure.
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: |
Ads access required (Ads add-on on legacy plans, included on usage-based plans),
or the connected LinkedIn account lacks the `rw_conversions` scope (reconnect required).
'404': { $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.
Google Ads only. A conversion action with the given name already
exists but has a different category. Use a different name or use
the existing destination. Error code: `IDEMPOTENCY_CONFLICT`.
'429':
description: Rate limit hit. Retry with backoff.
/v1/accounts/{accountId}/conversion-destinations/{destinationId}:
get:
x-resource-group: "ads"
operationId: getConversionDestination
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: Get a conversion destination
description: |
LinkedIn-only today. Returns the full destination record for one
conversion rule. The `adAccountId` query parameter is required because
LinkedIn rules are scoped to a sponsored ad account.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: destinationId, in: path, required: true, schema: { type: string } }
- name: adAccountId
in: query
required: true
schema: { type: string }
description: Numeric ID or full `urn:li:sponsoredAccount:{id}` URN.
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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. }
'429': { description: LinkedIn rate limit hit. Retry with backoff. }
patch:
x-resource-group: "ads"
operationId: updateConversionDestination
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: Update a conversion destination
description: |
Partial-update a conversion rule. LinkedIn-only today. Whitelisted
fields: `name`, `enabled`, attribution windows, `valueType`, `value`,
`attributionType`. The rule's `type` and parent ad account are
intentionally not exposed for update. Recreate the rule if those
need to change.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: destinationId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [adAccountId]
description: |
At least one mutable field beyond `adAccountId` is required;
the route returns 400 if no patch fields are provided.
properties:
adAccountId: { type: string }
name: { type: string, maxLength: 255 }
enabled:
type: boolean
description: |
Setting `false` is equivalent to calling DELETE: the
rule will appear as `inactive` afterwards.
attributionType:
type: string
enum: [LAST_TOUCH_BY_CAMPAIGN, LAST_TOUCH_BY_CONVERSION]
postClickAttributionWindowSize:
type: integer
enum: [1, 7, 30, 90, 365]
description: |
365 only allowed for LEAD, PURCHASE, ADD_TO_CART,
QUALIFIED_LEAD, SUBMIT_APPLICATION rule types.
viewThroughAttributionWindowSize:
type: integer
enum: [1, 7, 30, 90, 365]
description: |
365 only allowed for LEAD, PURCHASE, ADD_TO_CART,
QUALIFIED_LEAD, SUBMIT_APPLICATION rule types.
valueType:
type: string
enum: [DYNAMIC, FIXED, NO_VALUE]
value:
type: object
description: Used when `valueType=FIXED`.
properties:
currencyCode: { type: string, description: ISO 4217. }
amount: { type: string, description: 'Decimal string (e.g. "49.99").' }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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. }
'429': { description: LinkedIn rate limit hit. Retry with backoff. }
delete:
x-resource-group: "ads"
operationId: deleteConversionDestination
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: Delete a conversion destination
description: |
LinkedIn-only today. LinkedIn does not expose hard-delete on conversion
rules; what their UI calls "delete" is the same `enabled: false` flip
we apply here. The rule remains fetchable via GET with
`status: 'inactive'`; the unified discovery endpoint hides it by
default.
`adAccountId` may be passed as a query parameter (recommended) or as
a JSON body field for clients that can send DELETE bodies.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: destinationId, in: path, required: true, schema: { type: string } }
- name: adAccountId
in: query
schema: { type: string }
description: Required as query OR in JSON body.
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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. }
'429': { description: LinkedIn rate limit hit. Retry with backoff. }
/v1/accounts/{accountId}/conversion-destinations/{destinationId}/associations:
get:
x-resource-group: "ads"
operationId: listConversionAssociations
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: List associated campaigns
description: |
LinkedIn-only today. Returns the campaigns currently associated with
this conversion rule. Auto-association on rule creation
runs once at create time; campaigns created after the rule still need
explicit association.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: destinationId, in: path, required: true, schema: { type: string } }
- name: adAccountId
in: query
required: true
schema: { type: string }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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. }
'429': { description: LinkedIn rate limit hit. Retry with backoff. }
post:
x-resource-group: "ads"
operationId: addConversionAssociations
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: Associate campaigns
description: |
Associate one or more campaigns with this conversion rule. Returns a
per-campaign success/failure result so callers can retry only the
rows that failed (e.g. wrong campaign type for the rule's objective).
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: destinationId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [adAccountId, campaignIds]
properties:
adAccountId: { type: string }
campaignIds:
type: array
minItems: 1
maxItems: 100
items:
type: string
description: Numeric campaign ID or full `urn:li:sponsoredCampaign:{id}` URN.
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'200':
description: |
Per-campaign batch result. Status is 200 even when some rows
failed. Inspect `failed[]` for details. Inputs that fail local
URN validation are bucketed into `failed` without ever hitting
LinkedIn.
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [linkedinads] }
succeeded:
type: array
description: Numeric campaign IDs that were successfully associated.
items: { type: string }
failed:
type: array
items:
type: object
properties:
campaignId: { type: string }
reason: { type: string }
'400': { description: Invalid body. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Ads add-on or LinkedIn reconnect required. }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'405': { description: Platform does not support associations. }
'429': { description: LinkedIn rate limit hit. Retry with backoff. }
delete:
x-resource-group: "ads"
operationId: removeConversionAssociations
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: Remove associated campaigns
description: |
Remove one or more campaign associations from this conversion rule.
Pass `adAccountId` and `campaignIds` as query parameters
(`campaignIds` is comma-separated). The route also accepts a JSON
body with the same fields for clients that prefer DELETE-with-body,
but the documented surface is query-only because some SDK code
generators (e.g. Python) collapse query + body parameters with the
same name into a single kwarg.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: destinationId, in: path, required: true, schema: { type: string } }
- { name: adAccountId, in: query, required: true, schema: { type: string } }
- { name: campaignIds, in: query, required: true, schema: { type: string }, description: 'Comma-separated list of campaign IDs.' }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'200':
description: |
Per-campaign batch result. Status is 200 even when some rows
failed. Inspect `failed[]` for details.
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [linkedinads] }
succeeded:
type: array
description: Numeric campaign IDs that were successfully removed.
items: { type: string }
failed:
type: array
items:
type: object
properties:
campaignId: { type: string }
reason: { type: string }
'400':
description: |
Validation error: missing `adAccountId` or `campaignIds`,
campaignIds exceeds 100 entries per request, or `accountId` is not
a valid id.
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Ads add-on or LinkedIn reconnect required. }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'405': { description: Platform does not support associations. }
'429': { description: LinkedIn rate limit hit. Retry with backoff. }
/v1/accounts/{accountId}/conversion-destinations/{destinationId}/metrics:
get:
x-resource-group: "ads"
operationId: getConversionMetrics
tags: [Conversions]
x-platforms: ["meta", "google", "tiktok", "linkedin"]
summary: Get attribution metrics
description: |
LinkedIn-only today. Returns conversion-attribution metrics
(`externalWebsiteConversions`, `externalWebsitePostClickConversions`,
`externalWebsitePostViewConversions`, `conversionValueInLocalCurrency`,
`qualifiedLeads`, `costInLocalCurrency`) bucketed by date.
Date-range constraints (passed through from LinkedIn):
- `granularity=DAILY` is retained for ~6 months only
- `granularity=ALL` with a range > 6 months auto-rounds to month boundaries
- `granularity=MONTHLY`/`YEARLY` retains 24 months
Throttle: LinkedIn caps adAnalytics at 45M metric values per 5-minute
window across the calling token. Single-rule queries are well within
that limit; surfaces as 429 if hit.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: destinationId, in: path, required: true, schema: { type: string } }
- { name: adAccountId, in: query, required: true, schema: { type: string } }
- { name: startDate, in: query, required: true, schema: { type: string, pattern: '^\\d{4}-\\d{2}-\\d{2}$' } }
- { name: endDate, in: query, schema: { type: string, pattern: '^\\d{4}-\\d{2}-\\d{2}$' } }
- name: granularity
in: query
schema:
type: string
enum: [ALL, DAILY, MONTHLY, YEARLY]
default: DAILY
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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. }
'429': { description: LinkedIn analytics rate limit hit. }
/v1/whatsapp/conversions:
get:
x-resource-group: "ads"
operationId: listWhatsAppConversions
tags: [WhatsApp]
summary: List conversion events
description: |
Returns the most recent conversion events sent through
`POST /v1/whatsapp/conversions` for the given WhatsApp account.
Sourced from delivery logs (Axiom `late` dataset), so the visible
window is bounded by log retention (about 30 days). Useful for
rendering a "recent activity" panel on the conversions setup tab
without standing up a parallel persistence layer.
Per-event payload mirrors the structured log we write on every
successful send: `eventName`, `conversationId`, `eventsReceived`,
`eventsFailed`, `traceId`, `durationMs`, and the wall-clock
`timestamp`.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: query, required: true, schema: { type: string }, description: WhatsApp account ID }
- name: limit
in: query
required: false
description: Max events to return (1-200, default 50).
schema:
type: integer
minimum: 1
maximum: 200
default: 50
responses:
'200':
description: Recent conversion events
content:
application/json:
schema:
type: object
properties:
events:
type: array
items:
type: object
properties:
timestamp:
type: string
format: date-time
description: When the event was sent to Meta.
eventName:
type: string
description: One of LeadSubmitted, Purchase, AddToCart, InitiateCheckout, ViewContent.
conversationId:
type: [string, "null"]
eventsReceived:
type: [integer, "null"]
description: Number of events Meta accepted on this send (usually 1).
eventsFailed:
type: [integer, "null"]
description: Number of events Meta rejected (usually 0).
traceId:
type: [string, "null"]
description: Meta fbtrace_id for cross-referencing in Events Manager.
durationMs:
type: [integer, "null"]
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: WhatsApp account not found }
post:
x-resource-group: "ads"
operationId: sendWhatsAppConversion
tags: [WhatsApp]
summary: Send WhatsApp conversion event
description: |
Forward a WhatsApp Business Messaging conversion event (`LeadSubmitted`,
`Purchase`, `AddToCart`, `InitiateCheckout`, `ViewContent`) to Meta's
Conversions API with `action_source = business_messaging` and
`messaging_channel = whatsapp`. The endpoint looks up the originating
CTWA click ID (`ctwa_clid`) captured on the first inbound message of
the conversation and replays it on every event so Meta can attribute
the conversion back to the Click-to-WhatsApp ad that drove the chat.
Configuration prerequisite on the WhatsApp account metadata:
- `metaCapiDatasetId`: the Meta dataset ID linked to the WABA.
Provision one with `POST /v1/whatsapp/dataset`.
The WABA ID (already set automatically at connect time) is forwarded as
`user_data.whatsapp_business_account_id`, which is the per-channel
attribution identifier Meta requires for WhatsApp events. No Facebook
Page ID is needed (that field is the Messenger-branch identifier).
Identify the conversation by either `conversationId` (preferred) or
`phoneE164` (digits only, no `+`). At least one is required. If the
conversation has no captured `ctwa_clid`, the request returns 422
because there is nothing to attribute.
Token and dataset coupling: the WhatsApp account's accessToken must
have access to the configured `metaCapiDatasetId`. By default a WABA's
system-user token is scoped to the WABA's own Business Manager and
cannot post to a pixel owned by a different Business; Meta returns
code 100 in that case. Either share the dataset with the WhatsApp
app's Business in BM, or use a dataset already in the same Business
as the WABA.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [accountId, eventName, eventId]
description: |
In addition to the `required` list, at least one of
`conversationId` or `phoneE164` must be supplied (used to
resolve the originating CTWA conversation). The route enforces
this at the Zod boundary; OpenAPI's `required` cannot express
OR-required cleanly.
properties:
accountId:
type: string
minLength: 1
description: WhatsApp SocialAccount ID.
eventName:
type: string
enum: [LeadSubmitted, Purchase, AddToCart, InitiateCheckout, ViewContent]
description: |
Live-verified allowlist of event names accepted by Meta's
CAPI for Business Messaging (Graph API v25.0). Other
standard pixel events including `Lead`,
`CompleteRegistration`, `Subscribe`, `Schedule`, `Contact`,
`StartTrial`, `AddPaymentInfo`, `Search`, and
`SubmitApplication` are rejected with subcode 2804066
("Messaging Event Invalid Event Type") on
`action_source = business_messaging` events. Custom event
names are also rejected.
Use `LeadSubmitted` (NOT `Lead`) for lead-style conversions.
eventTime:
type: number
description: |
Unix seconds. Defaults to the time of the request when
omitted. Meta's attribution window is 7 days from click;
events older than that lose attribution.
eventId:
type: string
minLength: 1
description: |
Stable dedup key. Reuse to suppress duplicate events
(Meta dedupes against pixel events with the same id).
conversationId:
type: string
minLength: 1
description: |
Zernio Conversation `_id` (preferred lookup). The
conversation must have a captured `ctwa_clid` in metadata
(set automatically by the WhatsApp webhook on the first
inbound message after a CTWA ad click).
phoneE164:
type: string
minLength: 1
description: |
Contact phone number, digits only with no '+'. When used
in lieu of `conversationId`, the handler resolves to the
most recent CTWA-attributed conversation for this phone
on the supplied account.
value:
type: number
description: Conversion value (e.g. order total).
currency:
type: string
minLength: 3
maxLength: 3
description: ISO 4217 currency code (e.g. `USD`).
contentIds:
type: array
items: { type: string }
description: Optional product / content identifiers.
email:
type: string
format: email
description: User email. Normalized + SHA-256 hashed before sending to Meta.
externalId:
type: string
description: |
Stable customer identifier. Lowercased + SHA-256 hashed
before sending to Meta.
testCode:
type: string
description: |
Meta `test_event_code` passthrough. Routes the event to
the Test Events tab in Events Manager instead of the
production dataset, useful for development.
responses:
'200':
description: |
Event submitted to Meta. Inspect `eventsFailed` and `failures[]`
to detect partial failures. A 200 does not mean Meta accepted the
event; the status reflects "request reached Meta" only.
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [metaads] }
eventsReceived:
type: integer
description: Events accepted by Meta.
eventsFailed:
type: integer
description: Events rejected by Meta (see failures).
failures:
type: array
description: |
Per-event failure detail. Empty when all events were
accepted.
items:
type: object
properties:
eventIndex:
type: integer
description: Index into the submitted events array.
eventId:
type: string
description: Echoes back the eventId of the failed event.
message: { type: string }
code:
oneOf:
- { type: string }
- { type: integer }
traceId:
type: string
description: |
Meta `fbtrace_id` for debugging. Surface in support
tickets.
'400': { description: Invalid body. }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Conversation not found. }
'422':
description: |
Configuration missing (no `metaCapiDatasetId` on the account, set
it via POST /v1/whatsapp/dataset) OR the resolved conversation has
no captured `ctwa_clid`.
/v1/ads/messaging:
post:
x-resource-group: "ads"
operationId: createMessagingAd
tags: ["Messaging Ads"]
x-platforms: ["meta"]
summary: Create messaging ad
description: |-
Creates a click-to-message ad; `destination` selects where the tapped ad opens a
conversation: WhatsApp, the Page's Messenger inbox or the linked Instagram account's Direct inbox.
The ad set is created with the matching destination_type and
CONVERSATIONS optimization; the campaign objective defaults to OUTCOME_ENGAGEMENT.
Supports single-creative and multi-creative shapes. Supersedes POST /v1/ads/ctwa
(deprecated, equivalent to `destination: whatsapp`).
Existing posts and reels are supported through `existingPostId` or
`objectStoryId`, either per creative or at the top level. Omit fresh
media and copy for that creative. Optional `whatsappPhoneNumber` selects
a 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'201':
description: |
Ad(s) created and submitted for review. The route shares its handler with
`POST /v1/ads/ctwa`, so the body is the same tagged union discriminated by
`adType`: `single` carries `{ adType, ad, message }`, and `multi` carries
`{ adType, ads, platformCampaignId, platformAdSetId, message }`.
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' }
'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`
via a tel: link. The ad set is destination_type PHONE_CALL optimizing QUALITY_CALL
and the campaign objective defaults to OUTCOME_LEADS.
Supports the same single-creative and multi-creative shapes as CTWA.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/CtwaAdRequestBody'
- type: object
required: [phoneNumber, linkUrl]
properties:
phoneNumber:
type: string
description: 'E.164 number the CALL_NOW CTA dials (e.g. +34600111222).'
linkUrl:
type: string
format: uri
description: "Website shown as the creative's link. Required: Meta rejects tel: as link_data.link; the phone number rides only the CTA."
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'201':
description: |
Ad(s) created and submitted for review. The route shares its handler with
`POST /v1/ads/ctwa`, so the body is the same tagged union discriminated by
`adType`: `single` carries `{ adType, ad, message }`, and `multi` carries
`{ adType, ads, platformCampaignId, platformAdSetId, message }`.
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' }
'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.
Creates one or more Click-to-WhatsApp (CTWA) ads on Meta under a single campaign and ad set. When tapped, each ad opens a WhatsApp conversation with the business attached to the supplied Facebook Page. The full hierarchy (campaign, ad set, creative(s), ad(s)) is created and activated in one call. The CTA is locked to WHATSAPP_MESSAGE and the destination is hard-coded to api.whatsapp.com/send; Meta resolves the actual WhatsApp number from the Page-to-WA pairing configured in Page settings or Business Manager.
Supports two mutually-exclusive shapes:
- **Single-creative**: supply top-level `headline`, `body`, and one of `imageUrl` / `video`, or an `existingPostId` / `objectStoryId` reference. Creates 1 campaign + 1 ad set + 1 ad.
- **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.
**Attach shape.** Send `adSetId` (with either creative shape) to add the ads to an EXISTING messaging ad set instead of building a campaign, so the ad set keeps its learning phase, the way to refresh a CTWA creative without resetting delivery. The ad set then owns budget, targeting and schedule, so `budgetAmount`, `budgetType`, `endDate`, `objective`, `countries`, `interests` and `audienceId` are rejected with a 400 alongside it rather than silently dropped. The target ad set's `destination_type` must match the ad's destination (a WhatsApp ad needs a `WHATSAPP` ad set), otherwise Meta would accept an ad that never delivers.
Prerequisites enforced by Meta (surfaced as platform_error on failure): the Facebook Page must be paired with a verified WhatsApp Business number, the WhatsApp Business Account must be business-verified, and the Meta access token must carry ads_management.
Existing posts and reels are supported through `existingPostId` or
`objectStoryId`, either per creative or at the top level. Omit fresh
media and copy for that creative. Optional `whatsappPhoneNumber` selects
a 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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'201':
description: |
CTWA ad(s) created and submitted to Meta for review. Response is a
tagged union discriminated by `adType`:
- `adType: "single"` → single-creative request: `{ adType, ad,
message }` where `ad` is the persisted Ad document.
- `adType: "multi"` → multi-creative request: `{ adType, ads,
platformCampaignId, platformAdSetId, message }` where `ads` is
the array of N persisted Ad documents all sharing the returned
campaign and ad set IDs.
Generated SDK clients can narrow on `adType` instead of sniffing
for field presence.
content:
application/json:
schema:
oneOf:
- $ref: '#/components/schemas/CtwaSingleResponse'
- $ref: '#/components/schemas/CtwaMultiResponse'
discriminator:
propertyName: adType
mapping:
single: '#/components/schemas/CtwaSingleResponse'
multi: '#/components/schemas/CtwaMultiResponse'
'400': { description: Invalid body. }
'401': { $ref: '#/components/responses/Unauthorized' }
'403':
description: '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' }
'422':
description: Page is not connected to a verified WhatsApp number.
'502':
description: |
Meta rejected the request (e.g. WABA business verification
missing). Inspect `platformError` for the upstream Meta payload.
/v1/accounts/{accountId}/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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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." }
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
`customConversionId` for `promotedObject.customConversionId` on POST /v1/ads/create.
Removes the manual "create it in Ads Manager first" step.
**Reuse is ours, not Meta's.** Meta's create is not idempotent, so a retried request
would otherwise mint a duplicate carrying none of the original's optimisation history.
A non-archived conversion with the same `name` on the same `pixelId` is returned
instead of created, with `reused: true` and a 200 rather than a 201.
`rule` is forwarded verbatim in Meta's own grammar (e.g.
`{"url": {"i_contains": "thank-you"}}`); Meta validates it and rejects a malformed one
with "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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'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." }
/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
Meta Business Agent terms are accepted, whether an agent exists, whether it is on, and its
settings. `manualSteps` lists what Zernio can verify is still pending (accepting the terms
in WhatsApp Manager); `unverifiedSteps` lists what Meta exposes no state for (the payment
method in Billing Hub). Never fails for those pre-setup states; it reports them as flags.
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.
Requires the terms to be accepted; eligibility is checked first and an ineligible
number answers 403 `business_agent_not_eligible`. Not idempotent: call it once, then
configure knowledge and skills, then enable it through the settings. Configuration
calls made in the first minute can still answer `business_agent_not_found` while Meta
prepares the workspace.
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
on for new conversations; `false` stops it on every thread. Turning it on for `EVERYONE`
needs a payment method on the Business Agent billable account (Meta accepts the call but
delivers nothing without one); `ALLOWLISTED_ONLY` does not, which is how you test with a
few numbers before billing. `never_say_phrases` replaces the whole list.
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
extraction on the asset. Meta's limit is 100 MB. Two ways to send the file:
- JSON `{ url, fileName }`: Zernio downloads the file (public https URL, no redirects,
capped at 100 MB) and forwards it. Use this for anything above a few megabytes.
- multipart form-data with a `file` part (and an optional `fileName`): bounded by the
request body limit of about 4.5 MB; larger uploads must use the `url` form.
Not idempotent.
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`
(client credentials) or `kind: certificate` (mTLS client certificate). Meta has no call
that removes a credential layer; change the connector's `auth_type` or delete it instead.
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`,
polls a run started with POST. With `summaryIds`, returns the aggregated insight reports.
With `evalIds`, returns per-conversation evaluation details. One of the three at a time.
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
connected ads account can see. Pass `?adAccountId=act_...` (Meta only)
to scope the list to a single ad account; omit it to list every pixel
reachable by the token (the name is then suffixed with the ad account
it was discovered on, for disambiguation). The list view omits `code`.
Call `getTrackingTag` for the install snippet and full detail (Meta
only; OpenAI Ads has no get-by-id endpoint).
Meta (platform `metaads`) and OpenAI Ads (platform `openaiads`); other
platforms return 405. The `accountId` must be the ads SocialAccount
created by the Ads add-on connect flow (Meta) or the OpenAI Ads
connect flow, not a Facebook/Instagram posting account. Get your Meta
`act_...` ids from `GET /v1/ads/accounts`; `adAccountId` is ignored for
OpenAI Ads (one API key maps to exactly one ad account).
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string }, description: 'Ads SocialAccount id (platform `metaads` or `openaiads`).' }
- { name: adAccountId, in: query, required: false, schema: { type: string }, description: 'Optional, Meta only. Scope to one ad account, e.g. `act_123456789`. Ignored for OpenAI Ads.' }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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. }
'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`,
where `name` is the only input). Returns the created tag including its
install `code`. The pixel is owned by the Business Manager that owns the
ad account; a pixel created on a personal (non-BM) ad account ends up
with `ownerBusinessId: null` and can't be shared with other ad accounts.
Creating a Meta pixel does NOT install it. Install the returned `code`
snippet on the site, or send events server-side via
`POST /v1/ads/conversions`. The check `installed` is derived from
`lastFiredTime`.
OpenAI Ads: creates an OpenAI pixel AND provisions a Conversions API
key for it in the same call (`adAccountId` is required by this
endpoint but ignored: one API key maps to exactly one ad account, so
there's nothing to select). Returns 422 (`FEATURE_NOT_AVAILABLE`) if
the ad account isn't enabled for pixel management; contact your OpenAI
partner representative to enable it. There is no delete API for
OpenAI pixels. If the pixel is created but the Conversions API key
provisioning then fails, the pixel is left live on OpenAI (it cannot
be cleaned up) and the error message names the surviving pixel id and
warns against retrying, since a retry would create a second, orphaned
pixel.
NOT idempotent on either platform: each call creates a new pixel (and,
for OpenAI, a new Conversions API key plus, with `defaultEventType`, a
new conversion event setting). Do not retry blindly on
timeout. Meta (platform `metaads`) and OpenAI Ads (platform
`openaiads`); other platforms return 405.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string }, description: 'Ads SocialAccount id (platform `metaads` or `openaiads`).' }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [adAccountId, name]
properties:
adAccountId: { type: string, description: 'Meta ad account id, e.g. `act_123456789`. Required by this endpoint but ignored for OpenAI Ads.' }
name: { type: string, minLength: 1, maxLength: 200 }
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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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. }
'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,
`lastFiredTime`, `ownerBusinessId`, `isUnavailable`, etc. Meta only
(platform `metaads`); other platforms return 405. OpenAI Ads has no
get-by-id endpoint, so it 405s here too. Use
`GET /v1/accounts/{accountId}/tracking-tags` (list) instead.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: tagId, in: path, required: true, schema: { type: string }, description: 'Pixel id.' }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'400': { $ref: '#/components/responses/BadRequest' }
'200':
description: Tracking tag fetched
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [metaads] }
tag: { $ref: '#/components/schemas/TrackingTag' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'405': { description: Platform does not support fetching a tracking tag. }
'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),
`enableAutomaticMatching`, `automaticMatchingFields`,
`firstPartyCookieStatus`, `dataUseSetting`. At least one is required.
Returns the re-fetched canonical tag. Meta only (platform `metaads`);
other platforms return 405.
There is no DELETE: Meta has no API to delete a pixel. To stop using
one, unshare it from your ad accounts (`DELETE
.../tracking-tags/{tagId}/shared-accounts`) or disable it in Events
Manager.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: tagId, in: path, required: true, schema: { type: string }, description: 'Pixel id.' }
requestBody:
required: true
content:
application/json:
schema:
type: object
description: At least one field is required; the route returns 400 if the body is empty.
properties:
name: { type: string, minLength: 1, maxLength: 200 }
enableAutomaticMatching:
type: boolean
description: Meta Advanced Matching toggle (`enable_automatic_matching`).
automaticMatchingFields:
type: array
description: |
Which user fields Advanced Matching may collect. Meta's
terse codes: em=email, ph=phone, fn=first name, ln=last
name, ge=gender, db=date of birth, ct=city, st=state,
zp=zip.
items:
type: string
enum: [em, ph, fn, ln, ge, db, ct, st, zp, country, external_id]
firstPartyCookieStatus:
type: string
enum: [empty, first_party_cookie_disabled, first_party_cookie_enabled]
dataUseSetting:
type: string
enum: [advertising_and_analytics, analytics_only, empty]
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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. }
'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:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'400': { $ref: '#/components/responses/BadRequest' }
'200':
description: Shared ad accounts listed
content:
application/json:
schema:
type: object
properties:
platform: { type: string, enum: [metaads] }
sharedAccounts:
type: array
items: { $ref: '#/components/schemas/SharedAdAccount' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: 'Ads access required (Ads add-on on legacy plans, included on usage-based plans), or the Meta token lacks ads permissions (reconnect required).' }
'404': { $ref: '#/components/responses/AccountUnavailable' }
'405': { description: Platform does not support shared accounts. }
'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
account can use it. Requires that you administer both the pixel's owning
Business Manager and the target ad account; a pixel on a personal
(non-BM) ad account can't be shared (Meta will reject the call). Meta
only (platform `metaads`); other platforms return 405.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: tagId, in: path, required: true, schema: { type: string }, description: 'Pixel id.' }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [adAccountId]
properties:
adAccountId: { type: string, description: 'Ad account to share with, e.g. `act_123456789`.' }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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. }
'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
JSON body field for clients that can send DELETE bodies. Meta only
(platform `metaads`); other platforms return 405.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: tagId, in: path, required: true, schema: { type: string }, description: 'Pixel id.' }
- { name: adAccountId, in: query, required: false, schema: { type: string }, description: 'Ad account to unshare, e.g. `act_123456789`. May also be sent in the JSON body.' }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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. }
'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`).
Rows are passed through from Meta as-is; their shape depends on the
`aggregation` requested. Meta only (platform `metaads`); other platforms
return 405.
security:
- bearerAuth: []
parameters:
- { name: accountId, in: path, required: true, schema: { type: string } }
- { name: tagId, in: path, required: true, schema: { type: string }, description: 'Pixel id.' }
- name: aggregation
in: query
required: false
schema:
type: string
default: event
enum:
- event
- host
- url
- url_by_rule
- pixel_fire
- device_type
- device_os
- browser_type
- had_pii
- custom_data_field
- match_keys
- event_source
- event_detection_method
- event_processing_results
- event_total_counts
- event_value_count
description: Aggregation dimension. Defaults to `event`.
- { name: startTime, in: query, required: false, schema: { type: integer }, description: 'Unix seconds lower bound.' }
- { name: endTime, in: query, required: false, schema: { type: integer }, description: 'Unix seconds upper bound.' }
responses:
'409': { $ref: '#/components/responses/AccountConnectionRequired' }
'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. }
'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
returns them. Cursor-paginated: pass `limit` (1-50, default 20) and the
`cursor` from a previous response's `nextCursor`; `nextCursor` is null
when there are no more pages.
Supported on Shopify (platform `shopify`). Accounts on platforms
without blogs support return 400; a blogs-capable platform that lacks
this specific operation returns 405.
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
`handle` from the title when omitted.
Supported on Shopify (platform `shopify`). Accounts on platforms
without blogs support return 400; a blogs-capable platform that lacks
this specific operation returns 405.
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
`GET /v1/accounts/{accountId}/blogs`, not a Zernio id.
Supported on Shopify (platform `shopify`). Accounts on platforms
without blogs support return 400; a blogs-capable platform that lacks
this specific operation returns 405.
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
least one field is required (an empty body returns 400).
Supported on Shopify (platform `shopify`). Accounts on platforms
without blogs support return 400; a blogs-capable platform that lacks
this specific operation returns 405.
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
platform and is permanent; Zernio stores nothing to restore it from.
Supported on Shopify (platform `shopify`). Accounts on platforms
without blogs support return 400; a blogs-capable platform that lacks
this specific operation returns 405.
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,
default 20) and the `cursor` from a previous response's `nextCursor`;
`nextCursor` is null when there are no more pages.
Supported on Shopify (platform `shopify`). Accounts on platforms
without blogs support return 400; a blogs-capable platform that lacks
this specific operation returns 405.
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:
- `isPublished: false` keeps the article as a draft.
- A future `publishDate` schedules publication natively on the
platform; the platform publishes it at that time with no Zernio
queue involved.
- `seo.title` / `seo.description` map to Shopify's global `title_tag`
and `description_tag` metafields (the fields Shopify themes read for
the page title and meta description).
Supported on Shopify (platform `shopify`). Accounts on platforms
without blogs support return 400; a blogs-capable platform that lacks
this specific operation returns 405.
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
not belong to is a 404 (code blog_article_not_found).
Supported on Shopify (platform `shopify`). Accounts on platforms
without blogs support return 400; a blogs-capable platform that lacks
this specific operation returns 405.
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
(`title`, `bodyHtml`, `handle`, `tags`, `author`, `excerpt`, `image`,
`seo`, `isPublished`, `publishDate`); at least one field is required
(an empty body returns 400). `isPublished` and `publishDate` behave as
on create: `isPublished: false` unpublishes back to a draft and a
future `publishDate` schedules publication natively on the platform.
Supported on Shopify (platform `shopify`). Accounts on platforms
without blogs support return 400; a blogs-capable platform that lacks
this specific operation returns 405.
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
permanent; Zernio stores nothing to restore it from.
Supported on Shopify (platform `shopify`). Accounts on platforms
without blogs support return 400; a blogs-capable platform that lacks
this specific operation returns 405.
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
its hash. Check the user-typed code with
POST /v1/verify/verifications/{verificationId}/check.
Re-POSTing for the same (channel, to) while a verification is active
RESENDS a fresh code on the existing verification (200 with
`resend: true`) instead of creating a new one; resends are limited to
one per 60 seconds (429 with `retryAfterSeconds` inside the cooldown).
The stored brandName/codeLength/ttlMinutes win on a resend.
Codes deliver by SMS from a phone number on your account (`from`
optional when you own exactly one SMS-enabled number) and the message
uses a fixed template. Each accepted send bills one verification fee
plus the standard message rate.
security:
- bearerAuth: []
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [channel, to]
properties:
channel: { type: string, enum: [sms], description: 'SMS-only for now.' }
to: { type: string, description: "E.164 phone number." }
from: { type: string, description: "The SMS-enabled number on your account to send from. Defaults to your only SMS number." }
brandName: { type: string, maxLength: 30, description: "Your app or business name, rendered in the message. Defaults to your account name. Letters, numbers, and basic punctuation only." }
codeLength: { type: integer, minimum: 4, maximum: 8, default: 6 }
ttlMinutes: { type: integer, minimum: 1, maximum: 15, default: 10 }
responses:
'201':
description: Verification created and the code sent.
content:
application/json:
schema:
$ref: '#/components/schemas/Verification'
'200':
description: 'Active verification found: a fresh code was resent (`resend: true`).'
content:
application/json:
schema:
$ref: '#/components/schemas/Verification'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'403': { description: Verifications require usage-based billing. }
'404': { description: "The 'from' number is not an SMS-enabled number on this account." }
'409': { description: The recipient has opted out of messages from your number. }
'422': { description: "Verifications need an SMS-enabled number on your account; add one first." }
'429': { description: 'Resend cooldown or a send cap was hit; `retryAfterSeconds` says when to retry.' }
/v1/verify/verifications/{verificationId}:
get:
x-resource-group: "telephony"
operationId: getVerification
tags: [Verify]
summary: Get a verification
description: |
Current state of a verification. `status` is effective (a pending code
past its expiry reads as `expired`). Verification records are deleted
24 hours after creation, after which this returns 404.
security:
- bearerAuth: []
parameters:
- { name: verificationId, in: path, required: true, schema: { type: string } }
responses:
'200':
description: The verification.
content:
application/json:
schema:
$ref: '#/components/schemas/Verification'
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Verification not found (or already reaped). }
/v1/verify/verifications/{verificationId}/check:
post:
x-resource-group: "telephony"
operationId: checkVerification
tags: [Verify]
summary: Check a verification code
description: |
Verify the code the user typed. Wrong, expired, and exhausted codes
answer 200 with `valid: false` and the settled `status`. Only an
unknown id is a 404. A correct code consumes the verification
(single-use, `status: approved`) and fires the `verification.approved`
webhook; the 5th wrong attempt settles it as `max_attempts_reached`
and fires `verification.failed`.
security:
- bearerAuth: []
parameters:
- { name: verificationId, in: path, required: true, schema: { type: string } }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [code]
properties:
code: { type: string, pattern: '^\d{4,8}$' }
responses:
'200':
description: 'Check result: the verification plus `valid`.'
content:
application/json:
schema:
allOf:
- $ref: '#/components/schemas/Verification'
- type: object
properties:
valid: { type: boolean }
'400': { $ref: '#/components/responses/BadRequest' }
'401': { $ref: '#/components/responses/Unauthorized' }
'404': { description: Verification not found (or already reaped). }
/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:
- &a1
name: 'accountId'
in: 'query'
required: true
schema: &a7
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": &a2
$ref: '#/components/responses/BadRequest'
"401": &a3
$ref: '#/components/responses/Unauthorized'
"403":
description: 'Ads access required.'
"404": &a4
$ref: '#/components/responses/AccountUnavailable'
"409": &a5
$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:
- *a1
- 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": *a2
"401": *a3
"403": &a6
description: 'Ads access required. Partnership operations also require private beta access.'
"404": *a4
"409": *a5
"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:
- *a1
- 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: &a8
type: 'object'
properties:
id:
type: 'string'
permissionType:
type: 'string'
status:
type: 'string'
example:
permissions: []
"400": *a2
"401": *a3
"403": *a6
"404": *a4
"409": *a5
"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: *a7
creatorUsername:
type: 'string'
minLength: 1
revoke:
type: 'boolean'
example:
accountId: '507f1f77bcf86cd799439011'
creatorUsername: 'example_creator'
revoke: false
responses:
"200": &a9
description: 'Partnership permission state.'
content:
application/json:
schema:
type: 'object'
properties:
permission: *a8
example:
permission:
id: '123456789'
permissionType: 'AD'
status: 'PENDING'
"201": *a9
"400": *a2
"401": *a3
"403": *a6
"404": *a4
"409": *a5
"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' }